diff --git a/README.md b/README.md index af839c93..91bf53f9 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) -[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487) +[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) @@ -141,6 +141,11 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link +> [!NOTE] +> If SpotiFLAC is useful to you, consider supporting development: +> +> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) + --- ## Contributors @@ -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) | | [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | | -> [!NOTE] -> If SpotiFLAC is useful to you, consider supporting development: -> -> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) - > [!TIP] > **Star the repo** to get notified about all new releases directly from GitHub. diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d290213..577a9667 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,19 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - build/** + - .dart_tool/** + - lib/**/*.g.dart + - lib/l10n/*.dart + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + plugins: + - custom_lint + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` @@ -23,6 +36,13 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + avoid_dynamic_calls: true + cancel_subscriptions: true + close_sinks: true + +custom_lint: + rules: + - avoid_public_notifier_properties # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index fe9b8e07..f05542bc 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -137,14 +137,13 @@ class DownloadService : Service() { private fun startForegroundService() { isRunning = true - - // Acquire wake lock to prevent CPU sleep + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager wakeLock = powerManager.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG ).apply { - acquire(60 * 60 * 1000L) // 1 hour max + acquire(60 * 60 * 1000L) } val notification = buildNotification(0, 0) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 3fba0054..497da4cf 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject +import org.json.JSONTokener import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -129,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() { ) companion object { - // Minimum API level we consider "safe" for Impeller (Android 10+) private const val SAFE_API_FOR_IMPELLER = 29 - - // Known problematic GPU patterns (lowercase) + private val PROBLEMATIC_GPU_PATTERNS = listOf( - "adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm - "adreno (tm) 4", // Adreno 400 series - some have issues - "mali-4", // Mali-400 series - old ARM GPUs - "mali-t6", // Mali-T600 series - "mali-t7", // Mali-T700 series (some) - "powervr sgx", // PowerVR SGX series - old Imagination GPUs - "powervr ge8320", // PowerVR GE8320 - known issues - "gc1000", // Vivante GC1000 - "gc2000", // Vivante GC2000 + "adreno (tm) 3", + "adreno (tm) 4", + "mali-4", + "mali-t6", + "mali-t7", + "powervr sgx", + "powervr ge8320", + "gc1000", + "gc2000", ) - - // Known problematic chipsets/hardware (lowercase) + private val PROBLEMATIC_CHIPSETS = listOf( - "mt6762", // MediaTek Helio P22 with PowerVR GE8320 - "mt6765", // MediaTek Helio P35 with PowerVR GE8320 - "mt8768", // MediaTek tablet chip - "mp0873", // MediaTek variant - "msm8974", // Snapdragon 800/801 with Adreno 330 - "msm8226", // Snapdragon 400 with Adreno 305 - "msm8926", // Snapdragon 400 with Adreno 305 - "apq8084", // Snapdragon 805 (some issues) + "mt6762", + "mt6765", + "mt8768", + "mp0873", + "msm8974", + "msm8226", + "msm8926", + "apq8084", ) - - // Known problematic device models (lowercase) + private val PROBLEMATIC_MODELS = listOf( - "sm-t220", // Samsung Tab A7 Lite - "sm-t225", // Samsung Tab A7 Lite LTE - "hammerhead", // Nexus 5 (Adreno 330) + "sm-t220", + "sm-t225", + "hammerhead", ) /** * Check if device should use Skia instead of Impeller. @@ -173,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() { val model = Build.MODEL.lowercase(Locale.ROOT) val device = Build.DEVICE.lowercase(Locale.ROOT) - // 1. Check for explicitly problematic device models for (problematicModel in PROBLEMATIC_MODELS) { if (model.contains(problematicModel) || device.contains(problematicModel)) { android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel") @@ -181,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // 2. Check for problematic chipsets for (chipset in PROBLEMATIC_CHIPSETS) { if (hardware.contains(chipset) || board.contains(chipset)) { android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset") @@ -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) { - // For older Android, check GPU renderer if available val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) - // Check for known problematic GPUs for (pattern in PROBLEMATIC_GPU_PATTERNS) { if (gpuRenderer.contains(pattern)) { android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern") @@ -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) { android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety") return true } } - // 4. For Android 10+, still check for known problematic GPUs val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) for (pattern in PROBLEMATIC_GPU_PATTERNS) { if (gpuRenderer.contains(pattern)) { @@ -227,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() { */ private fun getGpuRenderer(): String { return try { - // This might not work before GL context is created, - // but worth trying for additional detection android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: "" } catch (e: Exception) { "" @@ -316,6 +304,7 @@ class MainActivity: FlutterFragmentActivity() { ".mp3" -> "audio/mpeg" ".opus" -> "audio/ogg" ".flac" -> "audio/flac" + ".lrc" -> "application/octet-stream" else -> "application/octet-stream" } } @@ -413,6 +402,38 @@ class MainActivity: FlutterFragmentActivity() { } } + private fun parseJsonValue(value: Any?): Any? { + return when (value) { + null, JSONObject.NULL -> null + is JSONObject -> { + val map = LinkedHashMap() + val keys = value.keys() + while (keys.hasNext()) { + val key = keys.next() + map[key] = parseJsonValue(value.opt(key)) + } + map + } + is JSONArray -> { + val list = ArrayList() + for (i in 0 until value.length()) { + list.add(parseJsonValue(value.opt(i))) + } + list + } + is Number, is Boolean, is String -> value + else -> value.toString() + } + } + + private fun parseJsonPayload(payload: String): Any { + return try { + parseJsonValue(JSONTokener(payload).nextValue()) ?: payload + } catch (_: Exception) { + payload + } + } + private fun startDownloadProgressStream(sink: EventChannel.EventSink) { stopDownloadProgressStream() downloadProgressEventSink = sink @@ -425,7 +446,7 @@ class MainActivity: FlutterFragmentActivity() { } if (payload != lastDownloadProgressPayload) { lastDownloadProgressPayload = payload - sink.success(payload) + sink.success(parseJsonPayload(payload)) } } catch (e: Exception) { android.util.Log.w( @@ -457,7 +478,7 @@ class MainActivity: FlutterFragmentActivity() { } if (payload != lastLibraryScanProgressPayload) { lastLibraryScanProgressPayload = payload - sink.success(payload) + sink.success(parseJsonPayload(payload)) } } catch (e: Exception) { android.util.Log.w( @@ -599,7 +620,6 @@ class MainActivity: FlutterFragmentActivity() { * Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE. */ private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String { - // Try DISPLAY_NAME first try { contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { @@ -610,7 +630,6 @@ class MainActivity: FlutterFragmentActivity() { } } catch (_: Exception) {} - // Try MIME_TYPE try { val mime = contentResolver.getType(uri) val ext = extFromMimeType(mime) @@ -836,8 +855,6 @@ class MainActivity: FlutterFragmentActivity() { val mimeType = mimeTypeForExt(outputExt) val fileName = buildSafFileName(req, outputExt) - // Check for existing file WITHOUT creating the directory first. - // This prevents empty folders from being created for duplicate downloads. val existingDir = findDocumentDir(treeUri, relativeDir) if (existingDir != null) { val existing = existingDir.findFile(fileName) @@ -852,7 +869,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Only create the directory now that we know we need to download val targetDir = ensureDocumentDir(treeUri, relativeDir) ?: return errorJson("Failed to access SAF directory") @@ -875,7 +891,6 @@ class MainActivity: FlutterFragmentActivity() { val respObj = JSONObject(response) if (respObj.optBoolean("success", false)) { // Extension providers write to a local temp path instead of the SAF FD. - // Copy the local file into the SAF document so it is not empty. val goFilePath = respObj.optString("file_path", "") if (goFilePath.isNotEmpty() && !goFilePath.startsWith("content://") && @@ -924,15 +939,10 @@ class MainActivity: FlutterFragmentActivity() { try { val docId = android.provider.DocumentsContract.getDocumentId(childUri) if (docId.isNullOrEmpty()) return null - - // Document IDs typically look like "primary:Music/Album/file.cue" - // Parent would be "primary:Music/Album" val lastSlash = docId.lastIndexOf('/') if (lastSlash <= 0) return null val parentDocId = docId.substring(0, lastSlash) - - // Build a tree document URI for the parent so it supports listing/findFile val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri) if (treeDocId.isNullOrEmpty()) return null @@ -957,21 +967,17 @@ class MainActivity: FlutterFragmentActivity() { val lines = File(cueTempPath).readLines() for (line in lines) { val trimmed = line.trim().let { l -> - // Strip BOM if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l } if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { val rest = trimmed.substring(5).trim() - // Parse: "filename" TYPE or filename TYPE val filename = if (rest.startsWith("\"")) { val endQuote = rest.indexOf('"', 1) if (endQuote > 0) rest.substring(1, endQuote) else rest } else { - // Last word is the type, everything else is the filename val parts = rest.split("\\s+".toRegex()) if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest } - // Return just the filename (strip any path separators) return filename.substringAfterLast("/").substringAfterLast("\\") } } @@ -1056,7 +1062,6 @@ class MainActivity: FlutterFragmentActivity() { val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() - // CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio val cueFiles = mutableListOf>() val visitedDirUris = mutableSetOf() val safChildLookupCache = mutableMapOf>() @@ -1141,7 +1146,6 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors - // --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio --- val cueReferencedAudioUris = mutableSetOf() for ((cueDoc, parentDir) in cueFiles) { @@ -1180,10 +1184,8 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Mark this audio file so we skip it in the regular audio pass cueReferencedAudioUris.add(audioDoc.uri.toString()) - // Copy audio to same temp dir so Go can resolve it val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) @@ -1197,7 +1199,6 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Rename temp audio to its original name so Go can find it by name val renamedAudio = File(tempDir, audioName) val tempAudioFile = File(tempAudioPath) if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { @@ -1240,14 +1241,12 @@ class MainActivity: FlutterFragmentActivity() { } } - // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } return "[]" } - // Skip audio files that are represented by CUE track entries if (cueReferencedAudioUris.contains(doc.uri.toString())) { scanned++ val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 @@ -1326,7 +1325,6 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } - // Parse existing files map: URI -> lastModified val existingFiles = mutableMapOf() try { val obj = JSONObject(existingFilesJson) @@ -1345,20 +1343,15 @@ class MainActivity: FlutterFragmentActivity() { } val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") - val audioFiles = mutableListOf>() // doc, path, lastModified - // CUE files to scan: (cueDoc, parentDir, lastModified) + val audioFiles = mutableListOf>() val cueFilesToScan = mutableListOf>() - // Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set val unchangedCueFiles = mutableListOf>() val currentUris = mutableSetOf() val visitedDirUris = mutableSetOf() val safChildLookupCache = mutableMapOf>() var traversalErrors = 0 - // Build a map of CUE base URIs -> existing virtual track URIs from the database. - // Virtual paths look like "content://...album.cue#track01". - // We need this to preserve virtual paths for unchanged CUE files. - val existingCueVirtualPaths = mutableMapOf>() // cueUri -> [virtualPaths] + val existingCueVirtualPaths = mutableMapOf>() for (key in existingFiles.keys) { val hashIdx = key.indexOf("#track") if (hashIdx > 0) { @@ -1367,7 +1360,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Collect all files with lastModified val queue: ArrayDeque> = ArrayDeque() queue.add(root to "") @@ -1423,8 +1415,6 @@ class MainActivity: FlutterFragmentActivity() { } queue.add(child to childPath) } else if (child.isFile) { - // Mark file as present first so it cannot be mis-classified as removed - // when provider-specific metadata calls (e.g., lastModified) fail. val uriStr = child.uri.toString() currentUris.add(uriStr) @@ -1436,18 +1426,15 @@ class MainActivity: FlutterFragmentActivity() { child.lastModified() } catch (_: Exception) { 0L } - // Check if any virtual track from this CUE exists with matching modTime val virtualPaths = existingCueVirtualPaths[uriStr] val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } if (existingModified != null && existingModified == lastModified) { - // CUE is unchanged — mark virtual paths as current so they aren't removed unchangedCueFiles.add(child to dir) for (vp in virtualPaths) { currentUris.add(vp) } } else { - // CUE is new or modified — needs scanning cueFilesToScan.add(Triple(child, dir, lastModified)) } } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { @@ -1458,7 +1445,6 @@ class MainActivity: FlutterFragmentActivity() { existingModified ?: 0L } - // Check if file is new or modified if (existingModified == null || existingModified != lastModified) { audioFiles.add(Triple(child, path, lastModified)) } @@ -1475,7 +1461,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Find removed files (in existing but not in current) val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } val totalFiles = currentUris.size val filesToProcess = audioFiles.size + cueFilesToScan.size @@ -1503,7 +1488,6 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors - // --- CUE first pass: parse new/modified CUE sheets --- val cueReferencedAudioUris = mutableSetOf() for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { @@ -1524,7 +1508,6 @@ class MainActivity: FlutterFragmentActivity() { var tempCuePath: String? = null var tempAudioPath: String? = null try { - // Copy CUE to temp tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") if (tempCuePath == null) { errors++ @@ -1533,10 +1516,8 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Extract the audio filename from the CUE sheet text val audioFileName = extractCueAudioFileName(tempCuePath) - // Find the referenced audio file as a sibling in the same SAF directory val audioDoc = resolveCueAudioSibling( parentDir = parentDir, cueName = cueName, @@ -1551,10 +1532,8 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Mark this audio file so we skip it in the regular audio pass cueReferencedAudioUris.add(audioDoc.uri.toString()) - // Copy audio to same temp dir so Go can resolve it val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) @@ -1568,7 +1547,6 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Rename temp audio to its original name so Go can find it by name val renamedAudio = File(tempDir, audioName) val tempAudioFile = File(tempAudioPath) if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { @@ -1576,7 +1554,6 @@ class MainActivity: FlutterFragmentActivity() { tempAudioPath = renamedAudio.absolutePath } - // Call Go to produce library scan entries for each CUE track val cueResultsJson = Gobackend.scanCueSheetForLibrary( tempCuePath, tempDir, @@ -1588,7 +1565,6 @@ class MainActivity: FlutterFragmentActivity() { for (j in 0 until cueArray.length()) { val trackObj = cueArray.getJSONObject(j) results.put(trackObj) - // Register each virtual path as current so deletion detection works val virtualPath = trackObj.optString("filePath", "") if (virtualPath.isNotBlank()) { currentUris.add(virtualPath) @@ -1621,9 +1597,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Discover audio siblings for unchanged CUE files so we skip them - // in the regular audio pass. Copy the .cue to temp (tiny file) to extract - // the audio filename, then find the sibling by name. for ((cueDoc, parentDir) in unchangedCueFiles) { var tempCue: String? = null try { @@ -1648,7 +1621,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _, lastModified) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } @@ -1661,7 +1633,6 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } - // Skip audio files that are represented by CUE track entries if (cueReferencedAudioUris.contains(doc.uri.toString())) { scanned++ val processed = skippedCount + scanned @@ -1715,7 +1686,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Recalculate removedUris now that CUE virtual paths have been registered val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } updateSafScanProgress { @@ -1893,7 +1863,6 @@ class MainActivity: FlutterFragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - // Update the intent so receive_sharing_intent can access the new data setIntent(intent) } @@ -2000,13 +1969,13 @@ class MainActivity: FlutterFragmentActivity() { val response = withContext(Dispatchers.IO) { Gobackend.getDownloadProgress() } - result.success(response) + result.success(parseJsonPayload(response)) } "getAllDownloadProgress" -> { val response = withContext(Dispatchers.IO) { Gobackend.getAllDownloadProgress() } - result.success(response) + result.success(parseJsonPayload(response)) } "initItemProgress" -> { val itemId = call.argument("item_id") ?: "" @@ -2553,7 +2522,6 @@ class MainActivity: FlutterFragmentActivity() { val tempPath = copyUriToTemp(uri) ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" try { - // Replace file_path with temp path for Go reqObj.put("file_path", tempPath) val raw = Gobackend.reEnrichFile(reqObj.toString()) val obj = JSONObject(raw) @@ -2631,7 +2599,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - // Deezer API methods "searchDeezerAll" -> { val query = call.argument("query") ?: "" val trackLimit = call.argument("track_limit") ?: 15 @@ -2642,7 +2609,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Tidal search API "searchTidalAll" -> { val query = call.argument("query") ?: "" val trackLimit = call.argument("track_limit") ?: 15 @@ -2653,7 +2619,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Qobuz search API "searchQobuzAll" -> { val query = call.argument("query") ?: "" val trackLimit = call.argument("track_limit") ?: 15 @@ -2783,7 +2748,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Log methods "getLogs" -> { val response = withContext(Dispatchers.IO) { Gobackend.getLogs() @@ -2816,7 +2780,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - // Extension System methods "initExtensionSystem" -> { val extensionsDir = call.argument("extensions_dir") ?: "" val dataDir = call.argument("data_dir") ?: "" @@ -2961,7 +2924,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - // Extension Auth API methods "getExtensionPendingAuth" -> { val extensionId = call.argument("extension_id") ?: "" val response = withContext(Dispatchers.IO) { @@ -3011,7 +2973,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension FFmpeg API "getPendingFFmpegCommand" -> { val commandId = call.argument("command_id") ?: "" val response = withContext(Dispatchers.IO) { @@ -3039,7 +3000,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension Custom Search API "customSearchWithExtension" -> { val extensionId = call.argument("extension_id") ?: "" val query = call.argument("query") ?: "" @@ -3055,7 +3015,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension URL Handler API "handleURLWithExtension" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { @@ -3100,7 +3059,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension Post-Processing API "runPostProcessing" -> { val filePath = call.argument("file_path") ?: "" val metadataJson = call.argument("metadata") ?: "" @@ -3144,7 +3102,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension Store "initExtensionStore" -> { val cacheDir = call.argument("cache_dir") ?: "" withContext(Dispatchers.IO) { @@ -3206,7 +3163,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - // Extension Home Feed (Explore) "getExtensionHomeFeed" -> { val extensionId = call.argument("extension_id") ?: "" val response = withContext(Dispatchers.IO) { @@ -3221,7 +3177,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Local Library Scanning "setLibraryCoverCacheDir" -> { val cacheDir = call.argument("cache_dir") ?: "" withContext(Dispatchers.IO) { @@ -3298,7 +3253,7 @@ class MainActivity: FlutterFragmentActivity() { Gobackend.getLibraryScanProgressJSON() } } - result.success(response) + result.success(parseJsonPayload(response)) } "cancelLibraryScan" -> { withContext(Dispatchers.IO) { @@ -3326,7 +3281,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // CUE Sheet Parsing "parseCueSheet" -> { val cuePath = call.argument("cue_path") ?: "" val audioDir = call.argument("audio_dir") ?: "" @@ -3338,17 +3292,14 @@ class MainActivity: FlutterFragmentActivity() { ?: return@withContext """{"error":"Failed to copy CUE file to temp"}""" var tempAudioPath: String? = null try { - // Extract audio filename from CUE text val audioFileName = extractCueAudioFileName(tempCuePath) - // Try to find the audio sibling in SAF var audioDoc: DocumentFile? = null val parentDir = safParentDir(uri) if (parentDir != null && !audioFileName.isNullOrBlank()) { audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } } - // Fallback: try common extensions with the CUE base name if (audioDoc == null && parentDir != null) { val cueName = try { DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: "" @@ -3367,7 +3318,6 @@ class MainActivity: FlutterFragmentActivity() { val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath if (audioDoc != null) { - // Copy audio to same temp dir with original name val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null @@ -3382,15 +3332,11 @@ class MainActivity: FlutterFragmentActivity() { } } - // Parse with audio in temp dir; Go will resolve there val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir) - // Replace the temp audio_path with the SAF content:// URI - // so Dart knows it's a SAF file and handles it accordingly if (audioDoc != null) { val resultObj = JSONObject(resultJson) resultObj.put("audio_path", audioDoc.uri.toString()) - // Also pass the original CUE URI for reference resultObj.put("cue_path", cuePath) resultObj.toString() } else { diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 7a906f8e..8f1b2921 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) { func isLyricsDescription(description string) bool { switch strings.ToLower(strings.TrimSpace(description)) { - case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc": + case + "lyrics", + "lyric", + "unsyncedlyrics", + "unsynced lyrics", + "uslt", + "lrc": return true default: return false diff --git a/go_backend/audio_metadata_mp3_test.go b/go_backend/audio_metadata_mp3_test.go new file mode 100644 index 00000000..b63f7b28 --- /dev/null +++ b/go_backend/audio_metadata_mp3_test.go @@ -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) + } +} diff --git a/go_backend/cover.go b/go_backend/cover.go index 10c89963..a368fb8f 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -17,6 +17,8 @@ const ( // Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) +var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`) + func convertSmallToMedium(imageURL string) string { if strings.Contains(imageURL, spotifySize300) { return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) @@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { maxURL := upgradeToMaxQuality(downloadURL) if maxURL != downloadURL { downloadURL = maxURL - // Log already printed by upgradeToMaxQuality for Deezer if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") { GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)") } @@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { } func upgradeToMaxQuality(coverURL string) string { - // Spotify CDN upgrade if strings.Contains(coverURL, spotifySize640) { return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) } - // Deezer CDN upgrade if strings.Contains(coverURL, "cdn-images.dzcdn.net") { return upgradeDeezerCover(coverURL) } + if strings.Contains(coverURL, "resources.tidal.com") { + return upgradeTidalCover(coverURL) + } + + if strings.Contains(coverURL, "static.qobuz.com") { + return upgradeQobuzCover(coverURL) + } + return coverURL } @@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string { return upgraded } +func upgradeTidalCover(coverURL string) string { + if !strings.Contains(coverURL, "resources.tidal.com") { + return coverURL + } + + upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg") + if upgraded != coverURL { + GoLog("[Cover] Tidal: upgraded to origin resolution") + } + return upgraded +} + +func upgradeQobuzCover(coverURL string) string { + if !strings.Contains(coverURL, "static.qobuz.com") { + return coverURL + } + + upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg") + if upgraded != coverURL { + GoLog("[Cover] Qobuz: upgraded to max resolution") + } + return upgraded +} + func GetCoverFromSpotify(imageURL string, maxQuality bool) string { if imageURL == "" { return "" } - // Always upgrade small to medium first result := convertSmallToMedium(imageURL) if maxQuality { diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go index 8cbf1f61..1a0dfa06 100644 --- a/go_backend/cue_parser.go +++ b/go_backend/cue_parser.go @@ -13,7 +13,6 @@ import ( // CueSheet represents a parsed .cue file type CueSheet struct { - // Album-level metadata Performer string `json:"performer"` Title string `json:"title"` FileName string `json:"file_name"` @@ -32,7 +31,6 @@ type CueTrack struct { Performer string `json:"performer"` ISRC string `json:"isrc,omitempty"` Composer string `json:"composer,omitempty"` - // Index positions in seconds (fractional) StartTime float64 `json:"start_time"` // INDEX 01 in seconds PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present) } @@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { continue } - // Handle BOM at start of file if strings.HasPrefix(line, "\xef\xbb\xbf") { line = strings.TrimPrefix(line, "\xef\xbb\xbf") line = strings.TrimSpace(line) @@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { upper := strings.ToUpper(line) - // REM commands (album-level metadata) if strings.HasPrefix(upper, "REM ") { matches := reRemCommand.FindStringSubmatch(line) if len(matches) == 3 { @@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { if strings.HasPrefix(upper, "FILE ") { rest := line[len("FILE "):] - // Extract filename and type - // Format: FILE "filename.flac" WAVE - // or: FILE filename.flac WAVE fname, ftype := parseCueFileLine(rest) sheet.FileName = fname sheet.FileType = ftype @@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { } if strings.HasPrefix(upper, "TRACK ") { - // Save previous track if currentTrack != nil { sheet.Tracks = append(sheet.Tracks, *currentTrack) } @@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { continue } - // SONGWRITER (used as composer sometimes) if strings.HasPrefix(upper, "SONGWRITER ") { value := unquoteCue(line[len("SONGWRITER "):]) if currentTrack != nil { @@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { } } - // Don't forget the last track if currentTrack != nil { sheet.Tracks = append(sheet.Tracks, *currentTrack) } diff --git a/go_backend/deezer.go b/go_backend/deezer.go index a389f3c8..3d76bcbf 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa for attempt := 0; attempt <= deezerMaxRetries; attempt++ { if attempt > 0 { - delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff + delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay) time.Sleep(delay) } @@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa lastErr = err errStr := err.Error() - // Check if error is retryable isRetryable := strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "connection refused") || diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index 974ffcf6..fdd0850d 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -319,7 +319,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err return "", fmt.Errorf("MusicDL error: %s", errMsg) } - // Try various response fields for download URL for _, key := range []string{"download_url", "url", "link"} { if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" { return strings.TrimSpace(urlVal), nil diff --git a/go_backend/exports.go b/go_backend/exports.go index 39184350..a5d17122 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { } // SetSongLinkNetworkOptions is kept for backward compatibility. -// It now applies global network compatibility options for all backend API requests. func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) { SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) } @@ -407,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = deezerErr - case "youtube": - youtubeResult, youtubeErr := downloadFromYouTube(req) - if youtubeErr == nil { - result = DownloadResult{ - FilePath: youtubeResult.FilePath, - BitDepth: 0, - SampleRate: 0, - Title: youtubeResult.Title, - Artist: youtubeResult.Artist, - Album: youtubeResult.Album, - ReleaseDate: youtubeResult.ReleaseDate, - TrackNumber: youtubeResult.TrackNumber, - DiscNumber: youtubeResult.DiscNumber, - ISRC: youtubeResult.ISRC, - LyricsLRC: youtubeResult.LyricsLRC, - } - } - err = youtubeErr default: return errorResponse("Unknown service: " + req.Service) } @@ -476,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) { serviceNormalized := strings.ToLower(serviceRaw) normalizedReq := req - if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) { + if isBuiltInProvider(serviceNormalized) { normalizedReq.Service = serviceNormalized } @@ -486,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) { } normalizedJSON := string(normalizedBytes) - if serviceNormalized == "youtube" { - return DownloadFromYouTube(normalizedJSON) - } - if req.UseExtensions { // Respect strict mode when auto fallback is disabled: // for built-in providers, route directly to selected service only. @@ -721,29 +698,57 @@ func ReadFileMetadata(filePath string) (string, error) { if isFlac { metadata, err := ReadMetadata(filePath) if err != nil { - return "", fmt.Errorf("failed to read metadata: %w", err) - } - result["title"] = metadata.Title - result["artist"] = metadata.Artist - result["album"] = metadata.Album - result["album_artist"] = metadata.AlbumArtist - result["date"] = metadata.Date - result["track_number"] = metadata.TrackNumber - result["disc_number"] = metadata.DiscNumber - result["isrc"] = metadata.ISRC - result["lyrics"] = metadata.Lyrics - result["genre"] = metadata.Genre - result["label"] = metadata.Label - result["copyright"] = metadata.Copyright - result["composer"] = metadata.Composer - result["comment"] = metadata.Comment + // File may have wrong extension (e.g. opus saved as .flac). + // Try Ogg/Opus parser as fallback before giving up. + GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err) + oggMeta, oggErr := ReadOggVorbisComments(filePath) + if oggErr == nil && oggMeta != nil { + result["title"] = oggMeta.Title + result["artist"] = oggMeta.Artist + result["album"] = oggMeta.Album + result["album_artist"] = oggMeta.AlbumArtist + result["date"] = oggMeta.Date + if oggMeta.Date == "" { + result["date"] = oggMeta.Year + } + result["track_number"] = oggMeta.TrackNumber + result["disc_number"] = oggMeta.DiscNumber + result["isrc"] = oggMeta.ISRC + result["lyrics"] = oggMeta.Lyrics + result["genre"] = oggMeta.Genre + result["composer"] = oggMeta.Composer + result["comment"] = oggMeta.Comment + quality, qualityErr := GetOggQuality(filePath) + if qualityErr == nil { + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + } + } else { + return "", fmt.Errorf("failed to read metadata: %w", err) + } + } else { + result["title"] = metadata.Title + result["artist"] = metadata.Artist + result["album"] = metadata.Album + result["album_artist"] = metadata.AlbumArtist + result["date"] = metadata.Date + result["track_number"] = metadata.TrackNumber + result["disc_number"] = metadata.DiscNumber + result["isrc"] = metadata.ISRC + result["lyrics"] = metadata.Lyrics + result["genre"] = metadata.Genre + result["label"] = metadata.Label + result["copyright"] = metadata.Copyright + result["composer"] = metadata.Composer + result["comment"] = metadata.Comment - quality, qualityErr := GetAudioQuality(filePath) - if qualityErr == nil { - result["bit_depth"] = quality.BitDepth - result["sample_rate"] = quality.SampleRate - if quality.SampleRate > 0 && quality.TotalSamples > 0 { - result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) + quality, qualityErr := GetAudioQuality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + if quality.SampleRate > 0 && quality.TotalSamples > 0 { + result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) + } } } } else if isM4A { @@ -910,7 +915,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } - // MP3/Opus: return metadata for Dart-side FFmpeg embedding resp := map[string]any{ "success": true, "method": "ffmpeg", @@ -1670,62 +1674,6 @@ func errorResponse(msg string) (string, error) { return string(jsonBytes), nil } -func DownloadFromYouTube(requestJSON string) (string, error) { - var req DownloadRequest - if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { - return errorResponse("Invalid request: " + err.Error()) - } - applySongLinkRegionFromRequest(&req) - defer closeOwnedOutputFD(req.OutputFD) - - req.TrackName = strings.TrimSpace(req.TrackName) - req.ArtistName = strings.TrimSpace(req.ArtistName) - req.AlbumName = strings.TrimSpace(req.AlbumName) - req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) - req.OutputDir = strings.TrimSpace(req.OutputDir) - req.OutputPath = strings.TrimSpace(req.OutputPath) - req.OutputExt = strings.TrimSpace(req.OutputExt) - - if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" { - AddAllowedDownloadDir(req.OutputDir) - } - - youtubeResult, err := downloadFromYouTube(req) - if err != nil { - return errorResponse(err.Error()) - } - - resp := DownloadResponse{ - Success: true, - Message: "Downloaded from YouTube", - FilePath: youtubeResult.FilePath, - Service: "youtube", - Title: youtubeResult.Title, - Artist: youtubeResult.Artist, - Album: youtubeResult.Album, - ReleaseDate: youtubeResult.ReleaseDate, - TrackNumber: youtubeResult.TrackNumber, - DiscNumber: youtubeResult.DiscNumber, - ISRC: youtubeResult.ISRC, - LyricsLRC: youtubeResult.LyricsLRC, - CoverURL: req.CoverURL, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - } - - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -func IsYouTubeURLExport(urlStr string) bool { - return IsYouTubeURL(urlStr) -} - -func ExtractYouTubeVideoIDExport(urlStr string) (string, error) { - return ExtractYouTubeVideoID(urlStr) -} - func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { if coverURL == "" { return fmt.Errorf("no cover URL provided") @@ -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", req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist) 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{}{ "track_name": req.TrackName, "artist_name": req.ArtistName, @@ -2187,12 +2133,6 @@ func LoadExtensionFromPath(filePath string) (string, error) { return "", err } - settingsStore := GetExtensionSettingsStore() - settings := settingsStore.GetAll(ext.ID) - if len(settings) > 0 { - manager.InitializeExtension(ext.ID, settings) - } - result := map[string]interface{}{ "id": ext.ID, "name": ext.Manifest.Name, @@ -2226,12 +2166,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) { return "", err } - settingsStore := GetExtensionSettingsStore() - settings := settingsStore.GetAll(ext.ID) - if len(settings) > 0 { - manager.InitializeExtension(ext.ID, settings) - } - result := map[string]interface{}{ "id": ext.ID, "display_name": ext.Manifest.DisplayName, @@ -3177,17 +3111,17 @@ func GetPostProcessingProvidersJSON() (string, error) { } func InitExtensionStoreJSON(cacheDir string) error { - InitExtensionStore(cacheDir) + initExtensionStore(cacheDir) return nil } func SetStoreRegistryURLJSON(registryURL string) error { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return fmt.Errorf("extension store not initialized") } - resolved, err := ResolveRegistryURL(registryURL) + resolved, err := resolveRegistryURL(registryURL) if err != nil { return err } @@ -3196,41 +3130,37 @@ func SetStoreRegistryURLJSON(registryURL string) error { return err } - store.SetRegistryURL(resolved) + store.setRegistryURL(resolved) return nil } func ClearStoreRegistryURLJSON() error { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return fmt.Errorf("extension store not initialized") } - store.SetRegistryURL("") - store.ClearCache() + store.setRegistryURL("") + store.clearCache() return nil } func GetStoreRegistryURLJSON() (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } - return store.GetRegistryURL(), nil + return store.getRegistryURL(), nil } func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } - if forceRefresh { - store.FetchRegistry(true) - } - - extensions, err := store.GetExtensionsWithStatus() + extensions, err := store.getExtensionsWithStatus(forceRefresh) if err != nil { return "", err } @@ -3244,12 +3174,12 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { } func SearchStoreExtensionsJSON(query, category string) (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } - extensions, err := store.SearchExtensions(query, category) + extensions, err := store.searchExtensions(query, category) if err != nil { return "", err } @@ -3263,12 +3193,12 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) { } func GetStoreCategoriesJSON() (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } - categories := store.GetCategories() + categories := store.getCategories() jsonBytes, err := json.Marshal(categories) if err != nil { return "", err @@ -3287,7 +3217,7 @@ func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) { } func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } @@ -3296,7 +3226,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { if err != nil { return "", err } - err = store.DownloadExtension(extensionID, destPath) + err = store.downloadExtension(extensionID, destPath) if err != nil { return "", err } @@ -3305,12 +3235,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { } func ClearStoreCacheJSON() error { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return fmt.Errorf("extension store not initialized") } - store.ClearCache() + store.clearCache() return nil } @@ -3324,12 +3254,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du if !ext.Enabled { return "", fmt.Errorf("extension '%s' is disabled", extensionID) } + vm, err := ext.lockReadyVM() + if err != nil { + return "", err + } + defer ext.VMMu.Unlock() // Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu // to avoid races with other provider calls (e.g. getAlbum/getPlaylist). - ext.VMMu.Lock() - defer ext.VMMu.Unlock() - script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { @@ -3339,7 +3271,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du })() `, functionName, functionName) - result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout) + result, err := RunWithTimeoutAndRecover(vm, script, timeout) if err != nil { return "", fmt.Errorf("%s failed: %w", functionName, err) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index d2a1f8d3..8f9e5dfd 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int { } type LoadedExtension struct { - ID string `json:"id"` - Manifest *ExtensionManifest `json:"manifest"` - VM *goja.Runtime `json:"-"` - VMMu sync.Mutex `json:"-"` - runtime *ExtensionRuntime - Enabled bool `json:"enabled"` - Error string `json:"error,omitempty"` - DataDir string `json:"data_dir"` - SourceDir string `json:"source_dir"` - IconPath string `json:"icon_path"` + ID string `json:"id"` + Manifest *ExtensionManifest `json:"manifest"` + VM *goja.Runtime `json:"-"` + VMMu sync.Mutex `json:"-"` + runtime *ExtensionRuntime + initialized bool + Enabled bool `json:"enabled"` + Error string `json:"error,omitempty"` + DataDir string `json:"data_dir"` + SourceDir string `json:"source_dir"` + IconPath string `json:"icon_path"` +} + +func getExtensionInitSettings(extensionID string) map[string]interface{} { + settings := GetExtensionSettingsStore().GetAll(extensionID) + if len(settings) == 0 { + return settings + } + + filtered := make(map[string]interface{}, len(settings)) + for key, value := range settings { + if strings.HasPrefix(key, "_") { + continue + } + filtered[key] = value + } + return filtered +} + +func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error { + if ext.VM == nil || ext.runtime == nil { + if err := initializeVMLocked(ext); err != nil { + ext.Error = err.Error() + ext.Enabled = false + return err + } + } + + if applyStoredSettings && !ext.initialized { + settings := getExtensionInitSettings(ext.ID) + if len(settings) > 0 { + if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil { + teardownVMLocked(ext) + ext.Error = err.Error() + ext.Enabled = false + return err + } + } else { + ext.initialized = true + } + } + + ext.Error = "" + return nil +} + +func (ext *LoadedExtension) ensureRuntimeReady() error { + ext.VMMu.Lock() + defer ext.VMMu.Unlock() + + return ensureRuntimeReadyLocked(ext, true) +} + +func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) { + ext.VMMu.Lock() + if err := ensureRuntimeReadyLocked(ext, true); err != nil { + ext.VMMu.Unlock() + return nil, err + } + return ext.VM, nil } type ExtensionManager struct { @@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens SourceDir: extDir, } - if err := m.initializeVM(ext); err != nil { + if err := validateExtensionLoad(ext); err != nil { ext.Error = err.Error() ext.Enabled = false - GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) + GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err) } m.extensions[manifest.Name] = ext @@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens return ext, nil } -func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { +func initializeVMLocked(ext *LoadedExtension) error { + ext.VM = nil + ext.runtime = nil + ext.initialized = false vm := goja.New() ext.VM = vm @@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { return nil } +func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { + ext.VMMu.Lock() + defer ext.VMMu.Unlock() + return initializeVMLocked(ext) +} + +func initializeExtensionWithSettingsLocked( + ext *LoadedExtension, + settings map[string]interface{}, +) error { + if ext.VM == nil { + return fmt.Errorf("Extension failed to load. Please reinstall the extension") + } + + settingsJSON, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("Failed to save settings") + } + + script := fmt.Sprintf(` + (function() { + var settings = %s; + if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') { + try { + extension.initialize(settings); + return { success: true }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: true, message: 'no initialize function' }; + })() + `, string(settingsJSON)) + + result, err := ext.VM.RunString(script) + if err != nil { + ext.Error = fmt.Sprintf("initialize failed: %v", err) + ext.Enabled = false + GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err) + return err + } + + if result != nil && !goja.IsUndefined(result) { + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok && !success { + errMsg := "unknown error" + if e, ok := resultMap["error"].(string); ok { + errMsg = e + } + ext.Error = errMsg + ext.Enabled = false + GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg) + return fmt.Errorf("initialize failed: %s", errMsg) + } + } + } + + ext.initialized = true + GoLog("[Extension] Initialized %s\n", ext.ID) + return nil +} + +func runCleanupLocked(ext *LoadedExtension) error { + if ext.VM != nil { + script := ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { + try { + extension.cleanup(); + return { success: true }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: true, message: 'no cleanup function' }; + })() + ` + + result, err := ext.VM.RunString(script) + if err != nil { + return err + } + + if result != nil && !goja.IsUndefined(result) { + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok && !success { + errMsg := "unknown error" + if e, ok := resultMap["error"].(string); ok { + errMsg = e + } + return fmt.Errorf("cleanup failed: %s", errMsg) + } + } + } + + if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) { + GoLog("[Extension] Cleanup called for %s\n", ext.ID) + } + } + return nil +} + +func teardownVMLocked(ext *LoadedExtension) { + if err := runCleanupLocked(ext); err != nil { + GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err) + } + if ext.runtime != nil { + if err := ext.runtime.flushStorageNow(); err != nil { + GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err) + } + ext.runtime.closeStorageFlusher() + } + ext.runtime = nil + ext.VM = nil + ext.initialized = false +} + +func validateExtensionLoad(ext *LoadedExtension) error { + ext.VMMu.Lock() + defer ext.VMMu.Unlock() + + if err := initializeVMLocked(ext); err != nil { + return err + } + teardownVMLocked(ext) + return nil +} + func (m *ExtensionManager) UnloadExtension(extensionID string) error { m.mu.Lock() defer m.mu.Unlock() @@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error { return fmt.Errorf("Extension not found") } - if ext.VM != nil { - cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null") - if err != nil { - GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err) - } else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) { - GoLog("[Extension] Cleanup called for %s\n", extensionID) - } - } - if ext.runtime != nil { - if err := ext.runtime.flushStorageNow(); err != nil { - GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err) - } - ext.runtime.closeStorageFlusher() - ext.runtime = nil - } + ext.VMMu.Lock() + teardownVMLocked(ext) + ext.VMMu.Unlock() delete(m.extensions, extensionID) GoLog("[Extension] Unloaded extension: %s\n", extensionID) @@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) return fmt.Errorf("Extension not found") } - ext.Enabled = enabled + if enabled { + ext.Enabled = true + if err := ext.ensureRuntimeReady(); err != nil { + store := GetExtensionSettingsStore() + ext.Enabled = false + _ = store.Set(extensionID, "_enabled", false) + return err + } + } else { + ext.Enabled = false + ext.Error = "" + ext.VMMu.Lock() + teardownVMLocked(ext) + ext.VMMu.Unlock() + } GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled]) store := GetExtensionSettingsStore() @@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx } } - if err := m.initializeVM(ext); err != nil { + if err := validateExtensionLoad(ext); err != nil { ext.Error = err.Error() ext.Enabled = false - GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) + GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err) } m.extensions[manifest.Name] = ext @@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, SourceDir: extDir, } - if err := m.initializeVM(ext); err != nil { + if wasEnabled { + if err := ext.ensureRuntimeReady(); err != nil { + GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err) + } + } else if err := validateExtensionLoad(ext); err != nil { ext.Error = err.Error() ext.Enabled = false - GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err) + GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err) } m.mu.Lock() @@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[ return fmt.Errorf("Extension not found") } - if ext.VM == nil { - return fmt.Errorf("Extension failed to load. Please reinstall the extension") - } + ext.VMMu.Lock() + defer ext.VMMu.Unlock() - settingsJSON, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("Failed to save settings") - } - - script := fmt.Sprintf(` - (function() { - var settings = %s; - if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') { - try { - extension.initialize(settings); - return { success: true }; - } catch (e) { - return { success: false, error: e.toString() }; - } - } - return { success: true, message: 'no initialize function' }; - })() - `, string(settingsJSON)) - - result, err := ext.VM.RunString(script) - if err != nil { - ext.Error = fmt.Sprintf("initialize failed: %v", err) - ext.Enabled = false - GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err) + if err := ensureRuntimeReadyLocked(ext, false); err != nil { return err } - - if result != nil && !goja.IsUndefined(result) { - exported := result.Export() - if resultMap, ok := exported.(map[string]interface{}); ok { - if success, ok := resultMap["success"].(bool); ok && !success { - errMsg := "unknown error" - if e, ok := resultMap["error"].(string); ok { - errMsg = e - } - ext.Error = errMsg - ext.Enabled = false - GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg) - return fmt.Errorf("initialize failed: %s", errMsg) - } - } - } - - GoLog("[Extension] Initialized %s\n", extensionID) - return nil + return initializeExtensionWithSettingsLocked(ext, settings) } func (m *ExtensionManager) CleanupExtension(extensionID string) error { @@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error { if ext.VM == nil { return nil } - - script := ` - (function() { - if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { - try { - extension.cleanup(); - return { success: true }; - } catch (e) { - return { success: false, error: e.toString() }; - } - } - return { success: true, message: 'no cleanup function' }; - })() - ` - - result, err := ext.VM.RunString(script) - if err != nil { + ext.VMMu.Lock() + defer ext.VMMu.Unlock() + if err := runCleanupLocked(ext); err != nil { GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err) return err } - - if result != nil && !goja.IsUndefined(result) { - exported := result.Export() - if resultMap, ok := exported.(map[string]interface{}); ok { - if success, ok := resultMap["success"].(bool); ok && !success { - errMsg := "unknown error" - if e, ok := resultMap["error"].(string); ok { - errMsg = e - } - GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg) - return fmt.Errorf("cleanup failed: %s", errMsg) - } - } - } - GoLog("[Extension] Cleaned up %s\n", extensionID) return nil } @@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) ( return nil, fmt.Errorf("extension not found: %s", extensionID) } - if ext.VM == nil { - return nil, fmt.Errorf("extension VM not initialized") + if err := ext.ensureRuntimeReady(); err != nil { + return nil, err } if !ext.Enabled { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index a3e46450..df7467f9 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper } } +func (p *ExtensionProviderWrapper) lockReadyVM() error { + vm, err := p.extension.lockReadyVM() + if err != nil { + return err + } + p.vm = vm + return nil +} + func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) @@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra if !p.extension.Enabled { return track, nil } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err) + return track, nil + } defer p.extension.VMMu.Unlock() trackJSON, err := json.Marshal(track) @@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -493,7 +510,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext const ExtDownloadTimeout = DownloadTimeout -func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { +func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) { if !p.extension.Manifest.IsDownloadProvider() { return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) } @@ -501,9 +518,18 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: err.Error(), + ErrorType: "init_error", + }, nil + } defer p.extension.VMMu.Unlock() + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { if len(call.Arguments) > 0 { @@ -1106,7 +1132,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro StartItemProgress(req.ItemID) } - result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { + result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) { if req.ItemID != "" { normalized := float64(percent) / 100.0 if normalized < 0 { @@ -1334,7 +1360,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro StartItemProgress(req.ItemID) } - result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { + result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) { if req.ItemID != "" { normalized := float64(percent) / 100.0 if normalized < 0 { @@ -1626,8 +1652,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() if options == nil { @@ -1707,8 +1734,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -1792,8 +1820,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() sourceJSON, _ := json.Marshal(sourceTrack) @@ -1862,8 +1891,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return &PostProcessResult{Success: false, Error: err.Error()}, nil + } defer p.extension.VMMu.Unlock() metadataJSON, _ := json.Marshal(metadata) @@ -1924,8 +1954,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return &PostProcessResult{Success: false, Error: err.Error()}, nil + } defer p.extension.VMMu.Unlock() metadataJSON, _ := json.Marshal(metadata) @@ -2182,8 +2213,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() // Use global variables to avoid JS injection issues with special characters in track/artist names diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 58068a42..7f2c0848 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -90,6 +90,9 @@ type ExtensionRuntime struct { dataDir string vm *goja.Runtime + activeDownloadMu sync.RWMutex + activeDownloadItemID string + storageMu sync.RWMutex storageCache map[string]interface{} storageLoaded bool @@ -139,6 +142,24 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { return runtime } +func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) { + r.activeDownloadMu.Lock() + defer r.activeDownloadMu.Unlock() + r.activeDownloadItemID = strings.TrimSpace(itemID) +} + +func (r *ExtensionRuntime) clearActiveDownloadItemID() { + r.activeDownloadMu.Lock() + defer r.activeDownloadMu.Unlock() + r.activeDownloadItemID = "" +} + +func (r *ExtensionRuntime) getActiveDownloadItemID() string { + r.activeDownloadMu.RLock() + defer r.activeDownloadMu.RUnlock() + return r.activeDownloadItemID +} + func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client { // Extension sandbox enforces HTTPS-only domains. Do not apply global // allow_http scheme downgrade here, because some extension APIs (e.g. diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 92312c98..9e442aea 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -205,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { defer out.Close() contentLength := resp.ContentLength + activeItemID := r.getActiveDownloadItemID() + if activeItemID != "" && contentLength > 0 { + SetItemBytesTotal(activeItemID, contentLength) + } + + var progressWriter interface{ Write([]byte) (int, error) } = out + if activeItemID != "" { + progressWriter = NewItemProgressWriter(out, activeItemID) + } var written int64 buf := make([]byte, 32*1024) for { nr, er := resp.Body.Read(buf) if nr > 0 { - nw, ew := out.Write(buf[0:nr]) + nw, ew := progressWriter.Write(buf[0:nr]) if nw < 0 || nr < nw { nw = 0 if ew == nil { @@ -220,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } written += int64(nw) if ew != nil { + if ew == ErrDownloadCancelled { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "download cancelled", + }) + } return r.vm.ToValue(map[string]interface{}{ "success": false, "error": fmt.Sprintf("failed to write file: %v", ew), diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 22ac2d0a..3a2c479b 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -21,7 +21,7 @@ const ( CategoryIntegration = "integration" ) -type StoreExtension struct { +type storeExtension struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"display_name,omitempty"` @@ -41,7 +41,7 @@ type StoreExtension struct { MinAppVersionAlt string `json:"minAppVersion,omitempty"` } -func (e *StoreExtension) getDisplayName() string { +func (e *storeExtension) getDisplayName() string { if e.DisplayName != "" { return e.DisplayName } @@ -51,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string { return e.Name } -func (e *StoreExtension) getDownloadURL() string { +func (e *storeExtension) getDownloadURL() string { if e.DownloadURL != "" { return e.DownloadURL } return e.DownloadURLAlt } -func (e *StoreExtension) getIconURL() string { +func (e *storeExtension) getIconURL() string { if e.IconURL != "" { return e.IconURL } return e.IconURLAlt } -func (e *StoreExtension) getMinAppVersion() string { +func (e *storeExtension) getMinAppVersion() string { if e.MinAppVersion != "" { return e.MinAppVersion } return e.MinAppVersionAlt } -type StoreRegistry struct { +type storeRegistry struct { Version int `json:"version"` UpdatedAt string `json:"updated_at"` - Extensions []StoreExtension `json:"extensions"` + Extensions []storeExtension `json:"extensions"` } -type StoreExtensionResponse struct { +type storeExtensionResponse struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"display_name"` @@ -97,8 +97,8 @@ type StoreExtensionResponse struct { HasUpdate bool `json:"has_update"` } -func (e *StoreExtension) ToResponse() StoreExtensionResponse { - return StoreExtensionResponse{ +func (e *storeExtension) toResponse() storeExtensionResponse { + resp := storeExtensionResponse{ ID: e.ID, Name: e.Name, DisplayName: e.getDisplayName(), @@ -108,25 +108,30 @@ func (e *StoreExtension) ToResponse() StoreExtensionResponse { DownloadURL: e.getDownloadURL(), IconURL: e.getIconURL(), Category: e.Category, - Tags: e.Tags, Downloads: e.Downloads, UpdatedAt: e.UpdatedAt, MinAppVersion: e.getMinAppVersion(), } + + if len(e.Tags) > 0 { + resp.Tags = append([]string(nil), e.Tags...) + } + + return resp } -type ExtensionStore struct { +type extensionStore struct { registryURL string cacheDir string - cache *StoreRegistry + cache *storeRegistry cacheMu sync.RWMutex cacheTime time.Time cacheTTL time.Duration } var ( - extensionStore *ExtensionStore - extensionStoreMu sync.Mutex + globalExtensionStore *extensionStore + extensionStoreMu sync.Mutex ) const ( @@ -134,24 +139,24 @@ const ( cacheFileName = "store_cache.json" ) -func InitExtensionStore(cacheDir string) *ExtensionStore { +func initExtensionStore(cacheDir string) *extensionStore { extensionStoreMu.Lock() defer extensionStoreMu.Unlock() - if extensionStore == nil { - extensionStore = &ExtensionStore{ + if globalExtensionStore == nil { + globalExtensionStore = &extensionStore{ registryURL: "", // No default - user must provide a registry URL cacheDir: cacheDir, cacheTTL: cacheTTL, } - extensionStore.loadDiskCache() + globalExtensionStore.loadDiskCache() } - return extensionStore + return globalExtensionStore } // SetRegistryURL updates the registry URL and clears the in-memory cache // so the next fetch will use the new URL. Disk cache is also cleared. -func (s *ExtensionStore) SetRegistryURL(registryURL string) { +func (s *extensionStore) setRegistryURL(registryURL string) { s.cacheMu.Lock() defer s.cacheMu.Unlock() @@ -173,19 +178,19 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) { } // GetRegistryURL returns the currently configured registry URL. -func (s *ExtensionStore) GetRegistryURL() string { +func (s *extensionStore) getRegistryURL() string { s.cacheMu.RLock() defer s.cacheMu.RUnlock() return s.registryURL } -func GetExtensionStore() *ExtensionStore { +func getExtensionStore() *extensionStore { extensionStoreMu.Lock() defer extensionStoreMu.Unlock() - return extensionStore + return globalExtensionStore } -func (s *ExtensionStore) loadDiskCache() { +func (s *extensionStore) loadDiskCache() { if s.cacheDir == "" { return } @@ -197,7 +202,7 @@ func (s *ExtensionStore) loadDiskCache() { } var cacheData struct { - Registry StoreRegistry `json:"registry"` + Registry storeRegistry `json:"registry"` CacheTime int64 `json:"cache_time"` } @@ -210,13 +215,13 @@ func (s *ExtensionStore) loadDiskCache() { LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) } -func (s *ExtensionStore) saveDiskCache() { +func (s *extensionStore) saveDiskCache() { if s.cacheDir == "" || s.cache == nil { return } cacheData := struct { - Registry StoreRegistry `json:"registry"` + Registry storeRegistry `json:"registry"` CacheTime int64 `json:"cache_time"` }{ Registry: *s.cache, @@ -232,11 +237,10 @@ func (s *ExtensionStore) saveDiskCache() { os.WriteFile(cachePath, data, 0644) } -func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { +func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) { s.cacheMu.Lock() defer s.cacheMu.Unlock() - // Check if a registry URL has been configured if s.registryURL == "" { return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first") } @@ -276,7 +280,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error return nil, fmt.Errorf("failed to read registry: %w", err) } - var registry StoreRegistry + var registry storeRegistry if err := json.Unmarshal(body, ®istry); err != nil { return nil, fmt.Errorf("failed to parse registry: %w", err) } @@ -289,8 +293,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error return ®istry, nil } -func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { - registry, err := s.FetchRegistry(false) +func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) { + registry, err := s.fetchRegistry(forceRefresh) if err != nil { return nil, err } @@ -304,29 +308,32 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er } } - result := make([]StoreExtensionResponse, len(registry.Extensions)) - for i, ext := range registry.Extensions { - resp := ext.ToResponse() + LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed)) + result := make([]storeExtensionResponse, 0, len(registry.Extensions)) + for i := range registry.Extensions { + ext := ®istry.Extensions[i] + resp := ext.toResponse() if installedVersion, ok := installed[ext.ID]; ok { resp.IsInstalled = true resp.InstalledVersion = installedVersion resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 } - result[i] = resp + result = append(result, resp) } + LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result)) return result, nil } -func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { - registry, err := s.FetchRegistry(false) +func (s *extensionStore) downloadExtension(extensionID string, destPath string) error { + registry, err := s.fetchRegistry(false) if err != nil { return err } - var ext *StoreExtension + var ext *storeExtension for _, e := range registry.Extensions { if e.ID == extensionID { ext = &e @@ -378,7 +385,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) // - https://github.com/owner/repo (with optional trailing path / .git) → resolved via // the GitHub API to discover the default branch, then converted to the raw URL // - Any other HTTPS URL → returned as-is (assumed to be a direct link) -func ResolveRegistryURL(input string) (string, error) { +func resolveRegistryURL(input string) (string, error) { input = strings.TrimSpace(input) if input == "" { return "", fmt.Errorf("registry URL is empty") @@ -389,7 +396,6 @@ func ResolveRegistryURL(input string) (string, error) { return input, nil } - // Try to match https://github.com//[/...] const ghPrefix = "https://github.com/" if !strings.HasPrefix(input, ghPrefix) { // Also accept http:// and upgrade silently. @@ -460,7 +466,7 @@ func requireHTTPSURL(rawURL string, context string) error { return nil } -func (s *ExtensionStore) GetCategories() []string { +func (s *extensionStore) getCategories() []string { return []string{ CategoryMetadata, CategoryDownload, @@ -470,8 +476,8 @@ func (s *ExtensionStore) GetCategories() []string { } } -func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { - extensions, err := s.GetExtensionsWithStatus() +func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) { + extensions, err := s.getExtensionsWithStatus(false) if err != nil { return nil, err } @@ -480,7 +486,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor return extensions, nil } - var result []StoreExtensionResponse + result := make([]storeExtensionResponse, 0, len(extensions)) queryLower := toLower(query) for _, ext := range extensions { @@ -493,7 +499,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor !containsIgnoreCase(ext.DisplayName, queryLower) && !containsIgnoreCase(ext.Description, queryLower) && !containsIgnoreCase(ext.Author, queryLower) { - // Check tags found := false for _, tag := range ext.Tags { if containsIgnoreCase(tag, queryLower) { @@ -513,7 +518,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor return result, nil } -func (s *ExtensionStore) ClearCache() { +func (s *extensionStore) clearCache() { s.cacheMu.Lock() defer s.cacheMu.Unlock() diff --git a/go_backend/go.mod b/go_backend/go.mod index fe67fe7e..7b1e584a 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/refraction-networking/utls v1.8.2 golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 golang.org/x/net v0.50.0 + golang.org/x/text v0.34.0 ) require ( @@ -24,6 +25,5 @@ require ( golang.org/x/mod v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect ) diff --git a/go_backend/go.sum b/go_backend/go.sum index 50e29433..3b71ae9b 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= -github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= @@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= -golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= -golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k= -golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 05e4af8f..b3a4c752 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf continue } - // Check for ISP blocking via HTTP status codes - // Some ISPs return 403 or 451 when blocking content if resp.StatusCode == 403 || resp.StatusCode == 451 { body, _ := io.ReadAll(resp.Body) resp.Body.Close() bodyStr := strings.ToLower(string(body)) - // Check if response looks like ISP blocking page ispBlockingIndicators := []string{ "blocked", "forbidden", "access denied", "not available in your", "restricted", "censored", "unavailable for legal", "blocked by", @@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { return nil } -// Returns true if ISP blocking was detected func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { ispErr := IsISPBlocking(err, requestURL) if ispErr != nil { @@ -553,7 +549,6 @@ func extractDomain(rawURL string) string { return "unknown" } -// If ISP blocking is detected, returns a more descriptive error func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { if err == nil { return nil diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index 4b09deb7..c3a90670 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { resp, err := sharedClient.Do(req) if err == nil { - // Check for Cloudflare challenge page (403 with specific markers) if resp.StatusCode == 403 || resp.StatusCode == 503 { body, readErr := io.ReadAll(resp.Body) resp.Body.Close() @@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { return resp, nil } - // Check if error might be TLS-related (Cloudflare blocking) errStr := strings.ToLower(err.Error()) tlsRelated := strings.Contains(errStr, "tls") || strings.Contains(errStr, "handshake") || diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index ee8874d1..c56b57d9 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) { continue } - // Skip audio files that are referenced by a .cue sheet - // (they will be represented by the cue sheet's track entries instead) if cueReferencedAudioFiles[filePath] { GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath)) continue @@ -557,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, return string(jsonBytes), nil } -// ScanLibraryFolderIncremental performs an incremental scan of the library folder -// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis) -// Only files that are new or have changed modification time will be scanned func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) { existingFiles := make(map[string]int64) if snapshotPath == "" { @@ -637,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi libraryScanProgress.TotalFiles = totalFiles libraryScanProgressMu.Unlock() - // Find files to scan (new or modified) var filesToScan []libraryAudioFileInfo skippedCount := 0 existingCueTrackModTimes := make(map[string]int64) @@ -653,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi for _, f := range currentFiles { existingModTime, exists := existingFiles[f.path] if !exists { - // For .cue files, also check if any virtual path entries exist if strings.ToLower(filepath.Ext(f.path)) == ".cue" { if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks { - // CUE file exists in DB via virtual paths; check if modTime changed if f.modTime == cueTrackModTime { skippedCount++ } else { @@ -675,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi var deletedPaths []string for existingPath := range existingFiles { - // For CUE virtual paths (e.g. "/path/album.cue#track01"), - // check if the base .cue file still exists on disk if idx := strings.LastIndex(existingPath, "#track"); idx > 0 { baseCuePath := existingPath[:idx] if currentPathSet[baseCuePath] { - continue // Base .cue file still exists, not deleted + continue } - // Base CUE file is gone, mark virtual path as deleted deletedPaths = append(deletedPaths, existingPath) } else if !currentPathSet[existingPath] { deletedPaths = append(deletedPaths, existingPath) @@ -713,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 - // Track audio files referenced by .cue sheets to avoid duplicates (incremental) cueReferencedAudioFilesInc := make(map[string]bool) parsedCueFiles := make(map[string]scannedCueFileInfo) for _, f := range filesToScan { @@ -748,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi ext := strings.ToLower(filepath.Ext(f.path)) - // Handle .cue files: produce multiple track results if ext == ".cue" { var cueResults []LibraryScanResult cueInfo, ok := parsedCueFiles[f.path] @@ -773,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi continue } - // Skip audio files referenced by .cue sheets if cueReferencedAudioFilesInc[f.path] { continue } diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 60d3aa85..ef3d855e 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) { return } - // Validate provider names validNames := map[string]bool{ LyricsProviderSpotifyAPI: true, LyricsProviderLRCLIB: true, @@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) { GoLog("[Lyrics] Provider order set to: %v\n", valid) } -// GetLyricsProviderOrder returns the current lyrics provider order. func GetLyricsProviderOrder() []string { lyricsProvidersMu.RLock() defer lyricsProvidersMu.RUnlock() @@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string { return result } -// GetAvailableLyricsProviders returns metadata about all available providers. func GetAvailableLyricsProviders() []map[string]interface{} { return []map[string]interface{}{ {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"}, @@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions { return opts } -// SetLyricsFetchOptions sets provider-specific lyric fetch behavior. func SetLyricsFetchOptions(opts LyricsFetchOptions) { normalized := normalizeLyricsFetchOptions(opts) @@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) { ) } -// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior. func GetLyricsFetchOptions() LyricsFetchOptions { lyricsFetchOptionsMu.RLock() 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) - // Cascade through all configured built-in providers for _, providerName := range providerOrder { GoLog("[Lyrics] Trying provider: %s\n", providerName) diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 1c4970cf..23a24d75 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -262,26 +262,35 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string { return fmt.Sprintf("%s (%s)", title, version) } +var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`) + +func qobuzUpscaleImageURL(url string) string { + if url == "" { + return "" + } + return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg") +} + func qobuzTrackAlbumImage(track *QobuzTrack) string { if track == nil { return "" } - return qobuzFirstNonEmpty( + return qobuzUpscaleImageURL(qobuzFirstNonEmpty( track.Album.Image.Large, track.Album.Image.Small, track.Album.Image.Thumbnail, - ) + )) } func qobuzAlbumImage(album *qobuzAlbumDetails) string { if album == nil { return "" } - return qobuzFirstNonEmpty( + return qobuzUpscaleImageURL(qobuzFirstNonEmpty( album.Image.Large, album.Image.Small, album.Image.Thumbnail, - ) + )) } func qobuzTrackArtistID(track *QobuzTrack) string { @@ -936,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) for i := range album.Tracks.Items { - tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i])) + track := &album.Tracks.Items[i] + track.Album.ID = album.ID + track.Album.Title = album.Title + track.Album.ReleaseDate = album.ReleaseDateOriginal + track.Album.Image = qobuzImageSet{ + Thumbnail: album.Image.Thumbnail, + Small: album.Image.Small, + Large: album.Image.Large, + } + track.Album.TracksCount = album.TracksCount + tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track)) } return &AlbumResponsePayload{ diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 34de563e..d9738e41 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1015,13 +1015,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) for _, item := range itemsModule.PagedList.Items { track := item.Item - if track.Album.ID == 0 { - track.Album.ID = headerModule.Album.ID - track.Album.Title = headerModule.Album.Title - track.Album.Cover = headerModule.Album.Cover - track.Album.ReleaseDate = headerModule.Album.ReleaseDate - track.Album.URL = headerModule.Album.URL - } + track.Album.ID = headerModule.Album.ID + track.Album.Title = headerModule.Album.Title + track.Album.Cover = headerModule.Album.Cover + track.Album.ReleaseDate = headerModule.Album.ReleaseDate + track.Album.URL = headerModule.Album.URL tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) } diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index cecf462d..b5089065 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -24,11 +24,9 @@ func normalizeLooseTitle(title string) string { b.WriteRune(r) case unicode.IsSpace(r): b.WriteByte(' ') - // Treat common separators as spaces. case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': b.WriteByte(' ') default: - // Drop other punctuation/symbols (including emoji) for loose matching. } } @@ -59,7 +57,6 @@ func normalizeLooseArtistName(name string) string { case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': b.WriteByte(' ') default: - // Drop remaining punctuation/symbols for loose artist matching. } } @@ -102,13 +99,11 @@ func normalizeSymbolOnlyTitle(title string) string { return b.String() } -// ==================== Shared Track Verification ==================== - // resolvedTrackInfo holds the metadata fetched from a provider for verification. type resolvedTrackInfo struct { Title string ArtistName string - Duration int // seconds + Duration int } // trackMatchesRequest checks whether a resolved track from a provider matches diff --git a/go_backend/youtube.go b/go_backend/youtube.go deleted file mode 100644 index 3bb65f5a..00000000 --- a/go_backend/youtube.go +++ /dev/null @@ -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 -} diff --git a/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go deleted file mode 100644 index e0f2ebbf..00000000 --- a/go_backend/youtube_quality_test.go +++ /dev/null @@ -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) - } -} diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 94e71f92..3ff69130 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -3,14 +3,14 @@ import 'package:flutter/foundation.dart'; /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.9.0'; - static const String buildNumber = '115'; + static const String version = '4.1.1'; + static const String buildNumber = '118'; static const String fullVersion = '$version+$buildNumber'; /// Shows "Internal" in debug builds, actual version in release. static String get displayVersion => kDebugMode ? 'Internal' : version; - static const String appName = 'SpotiFLAC'; + static const String appName = 'SpotiFLAC Mobile'; static const String copyright = '© 2026 SpotiFLAC'; static const String mobileAuthor = 'zarzet'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2fd44b83..64eee3fb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1432,6 +1432,66 @@ abstract class AppLocalizations { /// **'Playlists'** String get searchPlaylists; + /// Bottom sheet title for search sort options + /// + /// In en, this message translates to: + /// **'Sort Results'** + String get searchSortTitle; + + /// Sort option - default API order + /// + /// In en, this message translates to: + /// **'Default'** + String get searchSortDefault; + + /// Sort option - title ascending + /// + /// In en, this message translates to: + /// **'Title (A-Z)'** + String get searchSortTitleAZ; + + /// Sort option - title descending + /// + /// In en, this message translates to: + /// **'Title (Z-A)'** + String get searchSortTitleZA; + + /// Sort option - artist ascending + /// + /// In en, this message translates to: + /// **'Artist (A-Z)'** + String get searchSortArtistAZ; + + /// Sort option - artist descending + /// + /// In en, this message translates to: + /// **'Artist (Z-A)'** + String get searchSortArtistZA; + + /// Sort option - shortest duration first + /// + /// In en, this message translates to: + /// **'Duration (Shortest)'** + String get searchSortDurationShort; + + /// Sort option - longest duration first + /// + /// In en, this message translates to: + /// **'Duration (Longest)'** + String get searchSortDurationLong; + + /// Sort option - oldest release first + /// + /// In en, this message translates to: + /// **'Release Date (Oldest)'** + String get searchSortDateOldest; + + /// Sort option - newest release first + /// + /// In en, this message translates to: + /// **'Release Date (Newest)'** + String get searchSortDateNewest; + /// Tooltip - play button /// /// In en, this message translates to: @@ -2662,24 +2722,6 @@ abstract class AppLocalizations { /// **'Actual quality depends on track availability from the service'** String get qualityNote; - /// Note for YouTube service explaining lossy-only quality - /// - /// In en, this message translates to: - /// **'YouTube provides lossy audio only. Not part of lossless fallback.'** - String get youtubeQualityNote; - - /// Title for YouTube Opus bitrate setting - /// - /// In en, this message translates to: - /// **'YouTube Opus Bitrate'** - String get youtubeOpusBitrateTitle; - - /// Title for YouTube MP3 bitrate setting - /// - /// In en, this message translates to: - /// **'YouTube MP3 Bitrate'** - String get youtubeMp3BitrateTitle; - /// Setting - show quality picker /// /// In en, this message translates to: @@ -2860,6 +2902,18 @@ abstract class AppLocalizations { /// **'Artist/Album/ and Artist/Singles/'** String get albumFolderArtistAlbumSinglesSubtitle; + /// Album folder option with singles directly in artist folder + /// + /// In en, this message translates to: + /// **'Artist / Album (Singles flat)'** + String get albumFolderArtistAlbumFlat; + + /// Folder structure example for flat singles + /// + /// In en, this message translates to: + /// **'Artist/Album/ and Artist/song.flac'** + String get albumFolderArtistAlbumFlatSubtitle; + /// Button - delete selected tracks /// /// In en, this message translates to: @@ -5084,6 +5138,168 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Empty only'** String get editMetadataSelectEmpty; + + /// Header for active downloads section with count + /// + /// In en, this message translates to: + /// **'Downloading ({count})'** + String queueDownloadingCount(int count); + + /// Header label for downloaded items section in library + /// + /// In en, this message translates to: + /// **'Downloaded'** + String get queueDownloadedHeader; + + /// Shown while filter results are being computed + /// + /// In en, this message translates to: + /// **'Filtering...'** + String get queueFilteringIndicator; + + /// Track count label with plural support + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 track} other{{count} tracks}}'** + String queueTrackCount(int count); + + /// Album count label with plural support + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 album} other{{count} albums}}'** + String queueAlbumCount(int count); + + /// Empty state title when no album downloads exist + /// + /// In en, this message translates to: + /// **'No album downloads'** + String get queueEmptyAlbums; + + /// Empty state subtitle for album downloads + /// + /// In en, this message translates to: + /// **'Download multiple tracks from an album to see them here'** + String get queueEmptyAlbumsSubtitle; + + /// Empty state title when no single track downloads exist + /// + /// In en, this message translates to: + /// **'No single downloads'** + String get queueEmptySingles; + + /// Empty state subtitle for single track downloads + /// + /// In en, this message translates to: + /// **'Single track downloads will appear here'** + String get queueEmptySinglesSubtitle; + + /// Empty state title when download history is empty + /// + /// In en, this message translates to: + /// **'No download history'** + String get queueEmptyHistory; + + /// Empty state subtitle for download history + /// + /// In en, this message translates to: + /// **'Downloaded tracks will appear here'** + String get queueEmptyHistorySubtitle; + + /// Shown when all playlists are selected in selection mode + /// + /// In en, this message translates to: + /// **'All playlists selected'** + String get selectionAllPlaylistsSelected; + + /// Hint shown in playlist selection mode + /// + /// In en, this message translates to: + /// **'Tap playlists to select'** + String get selectionTapPlaylistsToSelect; + + /// Hint shown when no playlists are selected for deletion + /// + /// In en, this message translates to: + /// **'Select playlists to delete'** + String get selectionSelectPlaylistsToDelete; + + /// Title for audio analysis section + /// + /// In en, this message translates to: + /// **'Audio Quality Analysis'** + String get audioAnalysisTitle; + + /// Description for audio analysis tap-to-analyze prompt + /// + /// In en, this message translates to: + /// **'Verify lossless quality with spectrum analysis'** + String get audioAnalysisDescription; + + /// Loading text while analyzing audio + /// + /// In en, this message translates to: + /// **'Analyzing audio...'** + String get audioAnalysisAnalyzing; + + /// Sample rate metric label + /// + /// In en, this message translates to: + /// **'Sample Rate'** + String get audioAnalysisSampleRate; + + /// Bit depth metric label + /// + /// In en, this message translates to: + /// **'Bit Depth'** + String get audioAnalysisBitDepth; + + /// Channels metric label + /// + /// In en, this message translates to: + /// **'Channels'** + String get audioAnalysisChannels; + + /// Duration metric label + /// + /// In en, this message translates to: + /// **'Duration'** + String get audioAnalysisDuration; + + /// Nyquist frequency metric label + /// + /// In en, this message translates to: + /// **'Nyquist'** + String get audioAnalysisNyquist; + + /// File size metric label + /// + /// In en, this message translates to: + /// **'Size'** + String get audioAnalysisFileSize; + + /// Dynamic range metric label + /// + /// In en, this message translates to: + /// **'Dynamic Range'** + String get audioAnalysisDynamicRange; + + /// Peak amplitude metric label + /// + /// In en, this message translates to: + /// **'Peak'** + String get audioAnalysisPeak; + + /// RMS level metric label + /// + /// In en, this message translates to: + /// **'RMS'** + String get audioAnalysisRms; + + /// Total samples metric label + /// + /// In en, this message translates to: + /// **'Samples'** + String get audioAnalysisSamples; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d56bb267..15888ae5 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -772,6 +772,36 @@ class AppLocalizationsDe extends AppLocalizations { @override String get searchPlaylists => 'Playlisten'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Abspielen'; @@ -1449,16 +1479,6 @@ class AppLocalizationsDe extends AppLocalizations { String get qualityNote => 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab'; - @override - String get youtubeQualityNote => - 'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Qualität vor Download fragen'; @@ -1558,6 +1578,13 @@ class AppLocalizationsDe extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Künstler/Album/ und Künstler/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen'; @@ -2995,4 +3022,106 @@ class AppLocalizationsDe extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 594df94e..210342d2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -759,6 +759,36 @@ class AppLocalizationsEn extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; @@ -1425,16 +1455,6 @@ class AppLocalizationsEn extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1532,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2963,4 +2990,106 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 4dbe9944..f39ba881 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -759,6 +759,36 @@ class AppLocalizationsEs extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; @@ -1425,16 +1455,6 @@ class AppLocalizationsEs extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1532,6 +1552,13 @@ class AppLocalizationsEs extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2963,6 +2990,108 @@ class AppLocalizationsEs extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). @@ -4334,16 +4463,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get qualityNote => 'La calidad real depende de la disponibilidad de la pista del servicio'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Preguntar antes de descargar'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e80c2576..f0e783a5 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -761,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; @@ -1427,16 +1457,6 @@ class AppLocalizationsFr extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1534,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2964,4 +2991,106 @@ class AppLocalizationsFr extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index a387be72..d36edd4b 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -759,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; @@ -1425,16 +1455,6 @@ class AppLocalizationsHi extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1532,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2962,4 +2989,106 @@ class AppLocalizationsHi extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 12ee009f..45d363fa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -762,6 +762,36 @@ class AppLocalizationsId extends AppLocalizations { @override String get searchPlaylists => 'Playlist'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Putar'; @@ -1433,16 +1463,6 @@ class AppLocalizationsId extends AppLocalizations { String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; - @override - String get youtubeQualityNote => - 'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.'; - - @override - String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus'; - - @override - String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube'; - @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; @@ -1541,6 +1561,13 @@ class AppLocalizationsId extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artis/Album/ dan Artis/Single/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih'; @@ -2972,4 +2999,106 @@ class AppLocalizationsId extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b0b279ec..3d64d7d0 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -754,6 +754,36 @@ class AppLocalizationsJa extends AppLocalizations { @override String get searchPlaylists => 'プレイリスト'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => '再生'; @@ -1414,16 +1444,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート'; - @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; @@ -1519,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => '選択済みを削除'; @@ -2949,4 +2976,106 @@ class AppLocalizationsJa extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 97230226..69036d66 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -741,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations { @override String get searchPlaylists => '재생목록들'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => '재생'; @@ -1405,16 +1435,6 @@ class AppLocalizationsKo extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1512,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2942,4 +2969,106 @@ class AppLocalizationsKo extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 533e9b57..1ee2d0b9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -759,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; @@ -1425,16 +1455,6 @@ class AppLocalizationsNl extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1532,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2962,4 +2989,106 @@ class AppLocalizationsNl extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 64890c8c..444a91e0 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -759,6 +759,36 @@ class AppLocalizationsPt extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; @@ -1425,16 +1455,6 @@ class AppLocalizationsPt extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1532,6 +1552,13 @@ class AppLocalizationsPt extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2963,6 +2990,108 @@ class AppLocalizationsPt extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). @@ -4331,16 +4460,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get qualityNote => 'A qualidade real depende da faixa que estiver disponível no serviço'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d56adafe..fcd6c96b 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -773,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations { @override String get searchPlaylists => 'Плейлисты'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Воспроизвести'; @@ -1450,16 +1480,6 @@ class AppLocalizationsRu extends AppLocalizations { String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; - @override - String get youtubeQualityNote => - 'YouTube обеспечивает только звук с потерями(Lossy).'; - - @override - String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus'; - - @override - String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3'; - @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; @@ -1561,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Исполнитель/Альбом и Исполнитель/Сингл/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Удалить выбранные'; @@ -3022,4 +3049,106 @@ class AppLocalizationsRu extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index d4edbff2..42e95073 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -764,6 +764,36 @@ class AppLocalizationsTr extends AppLocalizations { @override String get searchPlaylists => 'Çalma Listeleri'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Oynat'; @@ -1431,16 +1461,6 @@ class AppLocalizationsTr extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1538,6 +1558,13 @@ class AppLocalizationsTr extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2968,4 +2995,106 @@ class AppLocalizationsTr extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c478d001..9b379784 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -759,6 +759,36 @@ class AppLocalizationsZh extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; @@ -1425,16 +1455,6 @@ class AppLocalizationsZh extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -1532,6 +1552,13 @@ class AppLocalizationsZh extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; @@ -2963,6 +2990,108 @@ class AppLocalizationsZh extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Chinese, as used in China (`zh_CN`). @@ -4297,16 +4426,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -6703,16 +6822,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 3dce0557..151c6a8d 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Qualität vor Download fragen", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7e882ab2..4107d8b9 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -999,6 +999,46 @@ "@searchPlaylists": { "description": "Search result category - playlists" }, + "searchSortTitle": "Sort Results", + "@searchSortTitle": { + "description": "Bottom sheet title for search sort options" + }, + "searchSortDefault": "Default", + "@searchSortDefault": { + "description": "Sort option - default API order" + }, + "searchSortTitleAZ": "Title (A-Z)", + "@searchSortTitleAZ": { + "description": "Sort option - title ascending" + }, + "searchSortTitleZA": "Title (Z-A)", + "@searchSortTitleZA": { + "description": "Sort option - title descending" + }, + "searchSortArtistAZ": "Artist (A-Z)", + "@searchSortArtistAZ": { + "description": "Sort option - artist ascending" + }, + "searchSortArtistZA": "Artist (Z-A)", + "@searchSortArtistZA": { + "description": "Sort option - artist descending" + }, + "searchSortDurationShort": "Duration (Shortest)", + "@searchSortDurationShort": { + "description": "Sort option - shortest duration first" + }, + "searchSortDurationLong": "Duration (Longest)", + "@searchSortDurationLong": { + "description": "Sort option - longest duration first" + }, + "searchSortDateOldest": "Release Date (Oldest)", + "@searchSortDateOldest": { + "description": "Sort option - oldest release first" + }, + "searchSortDateNewest": "Release Date (Newest)", + "@searchSortDateNewest": { + "description": "Sort option - newest release first" + }, "tooltipPlay": "Play", "@tooltipPlay": { "description": "Tooltip - play button" @@ -1869,18 +1909,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" @@ -2001,6 +2029,14 @@ "@albumFolderArtistAlbumSinglesSubtitle": { "description": "Folder structure example" }, + "albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)", + "@albumFolderArtistAlbumFlat": { + "description": "Album folder option with singles directly in artist folder" + }, + "albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac", + "@albumFolderArtistAlbumFlatSubtitle": { + "description": "Folder structure example for flat singles" + }, "downloadedAlbumDeleteSelected": "Delete Selected", "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" @@ -3903,5 +3939,129 @@ "editMetadataSelectEmpty": "Empty only", "@editMetadataSelectEmpty": { "description": "Button to select only fields that are currently empty" + }, + + "queueDownloadingCount": "Downloading ({count})", + "@queueDownloadingCount": { + "description": "Header for active downloads section with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueDownloadedHeader": "Downloaded", + "@queueDownloadedHeader": { + "description": "Header label for downloaded items section in library" + }, + "queueFilteringIndicator": "Filtering...", + "@queueFilteringIndicator": { + "description": "Shown while filter results are being computed" + }, + "queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "@queueTrackCount": { + "description": "Track count label with plural support", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}", + "@queueAlbumCount": { + "description": "Album count label with plural support", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueEmptyAlbums": "No album downloads", + "@queueEmptyAlbums": { + "description": "Empty state title when no album downloads exist" + }, + "queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here", + "@queueEmptyAlbumsSubtitle": { + "description": "Empty state subtitle for album downloads" + }, + "queueEmptySingles": "No single downloads", + "@queueEmptySingles": { + "description": "Empty state title when no single track downloads exist" + }, + "queueEmptySinglesSubtitle": "Single track downloads will appear here", + "@queueEmptySinglesSubtitle": { + "description": "Empty state subtitle for single track downloads" + }, + "queueEmptyHistory": "No download history", + "@queueEmptyHistory": { + "description": "Empty state title when download history is empty" + }, + "queueEmptyHistorySubtitle": "Downloaded tracks will appear here", + "@queueEmptyHistorySubtitle": { + "description": "Empty state subtitle for download history" + }, + "selectionAllPlaylistsSelected": "All playlists selected", + "@selectionAllPlaylistsSelected": { + "description": "Shown when all playlists are selected in selection mode" + }, + "selectionTapPlaylistsToSelect": "Tap playlists to select", + "@selectionTapPlaylistsToSelect": { + "description": "Hint shown in playlist selection mode" + }, + "selectionSelectPlaylistsToDelete": "Select playlists to delete", + "@selectionSelectPlaylistsToDelete": { + "description": "Hint shown when no playlists are selected for deletion" + }, + "audioAnalysisTitle": "Audio Quality Analysis", + "@audioAnalysisTitle": { + "description": "Title for audio analysis section" + }, + "audioAnalysisDescription": "Verify lossless quality with spectrum analysis", + "@audioAnalysisDescription": { + "description": "Description for audio analysis tap-to-analyze prompt" + }, + "audioAnalysisAnalyzing": "Analyzing audio...", + "@audioAnalysisAnalyzing": { + "description": "Loading text while analyzing audio" + }, + "audioAnalysisSampleRate": "Sample Rate", + "@audioAnalysisSampleRate": { + "description": "Sample rate metric label" + }, + "audioAnalysisBitDepth": "Bit Depth", + "@audioAnalysisBitDepth": { + "description": "Bit depth metric label" + }, + "audioAnalysisChannels": "Channels", + "@audioAnalysisChannels": { + "description": "Channels metric label" + }, + "audioAnalysisDuration": "Duration", + "@audioAnalysisDuration": { + "description": "Duration metric label" + }, + "audioAnalysisNyquist": "Nyquist", + "@audioAnalysisNyquist": { + "description": "Nyquist frequency metric label" + }, + "audioAnalysisFileSize": "Size", + "@audioAnalysisFileSize": { + "description": "File size metric label" + }, + "audioAnalysisDynamicRange": "Dynamic Range", + "@audioAnalysisDynamicRange": { + "description": "Dynamic range metric label" + }, + "audioAnalysisPeak": "Peak", + "@audioAnalysisPeak": { + "description": "Peak amplitude metric label" + }, + "audioAnalysisRms": "RMS", + "@audioAnalysisRms": { + "description": "RMS level metric label" + }, + "audioAnalysisSamples": "Samples", + "@audioAnalysisSamples": { + "description": "Total samples metric label" } } diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index dab6880d..e622a44c 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Preguntar antes de descargar", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 2fdf0477..5d2f1cae 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 0eeebf16..f2568821 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index eba93ff1..1cd89fba 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "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": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 44674a04..ad71c3f3 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus のビットレート", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 のビットレート", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "ダウンロード前に確認する", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index b872ef5c..1bec37ba 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 438519c3..ad97b6a3 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 9c7e843d..3cc94df2 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Perguntar qualidade antes de baixar", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 4a1ffc71..e9373f9c 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "Битрейт YouTube Opus", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "Битрейт YouTube MP3", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Спрашивать перед скачиванием", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 53ce4fb7..1afcb84d 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index db6943ab..ff232550 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index cf4f7b4a..598bb415 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/main.dart b/lib/main.dart index 89d5d93c..56c94ae2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> if (settings.localLibraryPath.isEmpty) return; if (settings.localLibraryAutoScan == 'off') return; - // Don't start a scan if one is already running. final libraryState = ref.read(localLibraryProvider); if (libraryState.isScanning) return; - // Determine cooldown based on auto-scan mode. final now = DateTime.now(); final prefs = await SharedPreferences.getInstance(); final lastScanned = readLocalLibraryLastScannedAt(prefs); @@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> } } - // All checks passed -- start an incremental scan. final iosBookmark = settings.localLibraryBookmark; ref .read(localLibraryProvider.notifier) diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index db55029b..d8ac97cb 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -12,13 +12,7 @@ enum DownloadStatus { skipped, } -enum DownloadErrorType { - unknown, - notFound, - rateLimit, - network, - permission, -} +enum DownloadErrorType { unknown, notFound, rateLimit, network, permission } @JsonSerializable() class DownloadItem { @@ -28,7 +22,8 @@ class DownloadItem { final DownloadStatus status; final double progress; final double speedMBps; - final int bytesReceived; // Bytes downloaded so far (for unknown size downloads) + final int bytesReceived; // Bytes downloaded so far + final int bytesTotal; // Total bytes when the server provides content length final String? filePath; final String? error; final DownloadErrorType? errorType; @@ -44,6 +39,7 @@ class DownloadItem { this.progress = 0.0, this.speedMBps = 0.0, this.bytesReceived = 0, + this.bytesTotal = 0, this.filePath, this.error, this.errorType, @@ -60,6 +56,7 @@ class DownloadItem { double? progress, double? speedMBps, int? bytesReceived, + int? bytesTotal, String? filePath, String? error, DownloadErrorType? errorType, @@ -75,6 +72,7 @@ class DownloadItem { progress: progress ?? this.progress, speedMBps: speedMBps ?? this.speedMBps, bytesReceived: bytesReceived ?? this.bytesReceived, + bytesTotal: bytesTotal ?? this.bytesTotal, filePath: filePath ?? this.filePath, error: error ?? this.error, errorType: errorType ?? this.errorType, @@ -86,7 +84,7 @@ class DownloadItem { String get errorMessage { if (error == null) return ''; - + switch (errorType) { case DownloadErrorType.notFound: return 'Song not found on any service'; diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 961e6d6d..7aee835c 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -16,6 +16,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( progress: (json['progress'] as num?)?.toDouble() ?? 0.0, speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0, bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0, + bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0, filePath: json['filePath'] as String?, error: json['error'] as String?, errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), @@ -33,6 +34,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => 'progress': instance.progress, 'speedMBps': instance.speedMBps, 'bytesReceived': instance.bytesReceived, + 'bytesTotal': instance.bytesTotal, 'filePath': instance.filePath, 'error': instance.error, 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], diff --git a/lib/models/settings.dart b/lib/models/settings.dart index c2f2eed6..63f87abe 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -42,10 +42,6 @@ class AppSettings { final String lyricsMode; final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' - final int - youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps) - final int - youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps) final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE final bool @@ -121,8 +117,6 @@ class AppSettings { this.locale = 'system', this.lyricsMode = 'embed', this.tidalHighFormat = 'mp3_320', - this.youtubeOpusBitrate = 256, - this.youtubeMp3Bitrate = 320, this.useAllFilesAccess = false, this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', @@ -189,8 +183,6 @@ class AppSettings { String? locale, String? lyricsMode, String? tidalHighFormat, - int? youtubeOpusBitrate, - int? youtubeMp3Bitrate, bool? useAllFilesAccess, bool? autoExportFailedDownloads, String? downloadNetworkMode, @@ -257,8 +249,6 @@ class AppSettings { locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, - youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate, - youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 914e224f..d78bc81e 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', - youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256, - youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, @@ -125,8 +123,6 @@ Map _$AppSettingsToJson( 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'tidalHighFormat': instance.tidalHighFormat, - 'youtubeOpusBitrate': instance.youtubeOpusBitrate, - 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate, 'useAllFilesAccess': instance.useAllFilesAccess, 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a457b3d2..123a0c97 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.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/settings.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -262,8 +263,14 @@ class DownloadHistoryState { class DownloadHistoryNotifier extends Notifier { static const int _safRepairBatchSize = 20; static const int _safRepairMaxPerLaunch = 60; + static const int _orphanCleanupMaxPerLaunch = 80; 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; bool _isLoaded = false; bool _isSafRepairInProgress = false; @@ -320,20 +327,29 @@ class DownloadHistoryNotifier extends Notifier { unawaited( Future.delayed(_startupMaintenanceDelay, () async { try { + final prefs = await SharedPreferences.getInstance(); + if (Platform.isAndroid) { await _repairMissingSafEntries( initialItems, maxItems: _safRepairMaxPerLaunch, + prefs: prefs, ); + await Future.delayed(_startupMaintenanceStepGap); } - await cleanupOrphanedDownloads(); + await _cleanupOrphanedDownloadsIncremental( + maxItems: _orphanCleanupMaxPerLaunch, + prefs: prefs, + ); + await Future.delayed(_startupMaintenanceStepGap); final currentItems = state.items; if (currentItems.isNotEmpty) { await _backfillAudioMetadata( currentItems, maxItems: _audioMetadataBackfillMaxPerLaunch, + prefs: prefs, ); } } catch (e, stack) { @@ -344,6 +360,30 @@ class DownloadHistoryNotifier extends Notifier { ); } + 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 _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) { try { final parsed = Uri.parse(uri); @@ -357,6 +397,7 @@ class DownloadHistoryNotifier extends Notifier { Future _repairMissingSafEntries( List items, { required int maxItems, + required SharedPreferences prefs, }) async { if (_isSafRepairInProgress || items.isEmpty) { return; @@ -378,22 +419,40 @@ class DownloadHistoryNotifier extends Notifier { continue; } candidateIndexes.add(i); - if (candidateIndexes.length >= maxItems) break; } 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; return; } final updatedItems = [...items]; + final persistedUpdates = >[]; var changed = false; var repairedCount = 0; var verifiedCount = 0; try { - for (var c = 0; c < candidateIndexes.length; c++) { - final i = candidateIndexes[c]; + for (var c = 0; c < selectedIndexes.length; c++) { + final i = selectedIndexes[c]; final item = items[i]; final rawPath = item.filePath.trim(); final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath); @@ -408,7 +467,7 @@ class DownloadHistoryNotifier extends Notifier { updatedItems[i] = verified; changed = true; verifiedCount++; - await _db.upsert(verified.toJson()); + persistedUpdates.add(verified.toJson()); continue; } } @@ -445,22 +504,29 @@ class DownloadHistoryNotifier extends Notifier { updatedItems[i] = updated; changed = true; repairedCount++; - await _db.upsert(updated.toJson()); + persistedUpdates.add(updated.toJson()); } catch (e) { _historyLog.w('Failed to repair SAF URI: $e'); } if ((c + 1) % _safRepairBatchSize == 0) { - await Future.delayed(const Duration(milliseconds: 16)); + await Future.delayed(const Duration(milliseconds: 16)); } } if (changed) { + await _db.upsertBatch(persistedUpdates); state = state.copyWith(items: updatedItems); _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 { _isSafRepairInProgress = false; } @@ -556,6 +622,7 @@ class DownloadHistoryNotifier extends Notifier { Future _backfillAudioMetadata( List items, { required int maxItems, + required SharedPreferences prefs, }) async { if (_isAudioMetadataBackfillInProgress || items.isEmpty) { return; @@ -563,15 +630,40 @@ class DownloadHistoryNotifier extends Notifier { _isAudioMetadataBackfillInProgress = true; try { + final candidateIndexes = []; + 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? updatedItems; + final persistedUpdates = >[]; var refreshedCount = 0; - for (final item in items) { - if (refreshedCount >= maxItems) { - break; - } - if (!_shouldBackfillAudioMetadata(item)) { - continue; - } + for (final index in selectedIndexes) { + final item = items[index]; final probed = await _probeAudioMetadata( item.filePath, @@ -598,15 +690,29 @@ class DownloadHistoryNotifier extends Notifier { continue; } - await updateAudioMetadataForItem( - id: item.id, + final updated = item.copyWith( quality: resolvedQuality, bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, ); + updatedItems ??= [...items]; + updatedItems[index] = updated; + persistedUpdates.add(updated.toJson()); 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) { _historyLog.i( 'Audio metadata backfill refreshed $refreshedCount items', @@ -656,7 +762,7 @@ class DownloadHistoryNotifier extends Notifier { _historyLog.d('Added new history entry: ${mergedItem.trackName}'); } - _db.upsert(mergedItem.toJson()).catchError((e) { + _db.upsert(mergedItem.toJson()).catchError((Object e) { _historyLog.e('Failed to save to database: $e'); }); } @@ -665,7 +771,7 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith( items: state.items.where((item) => item.id != id).toList(), ); - _db.deleteById(id).catchError((e) { + _db.deleteById(id).catchError((Object e) { _historyLog.e('Failed to delete from database: $e'); }); } @@ -674,7 +780,7 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith( items: state.items.where((item) => item.spotifyId != spotifyId).toList(), ); - _db.deleteBySpotifyId(spotifyId).catchError((e) { + _db.deleteBySpotifyId(spotifyId).catchError((Object e) { _historyLog.e('Failed to delete from database: $e'); }); _historyLog.d('Removed item with spotifyId: $spotifyId'); @@ -768,9 +874,6 @@ class DownloadHistoryNotifier extends Notifier { 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 = [ '.flac', '.m4a', @@ -781,11 +884,7 @@ class DownloadHistoryNotifier extends Notifier { '.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 _findConvertedSibling(String originalPath) async { - // Strip the current extension to get the base path. final dotIndex = originalPath.lastIndexOf('.'); if (dotIndex < 0) return null; final basePath = originalPath.substring(0, dotIndex); @@ -801,11 +900,16 @@ class DownloadHistoryNotifier extends Notifier { return null; } - Future cleanupOrphanedDownloads() async { - _historyLog.i('Starting orphaned downloads cleanup...'); - - final entries = await _db.getAllEntriesWithPaths(); + Future< + ({ + List orphanedIds, + Map replacementPaths, + Map pathById, + }) + > + _inspectOrphanedEntries(List> entries) async { final orphanedIds = []; + final replacementPaths = {}; final pathById = {}; const checkChunkSize = 16; @@ -824,14 +928,12 @@ class DownloadHistoryNotifier extends Notifier { try { if (await fileExists(filePath)) return MapEntry(id, true); - // Original file missing -- check for a converted sibling. final sibling = await _findConvertedSibling(filePath); if (sibling != null) { _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. - await _db.updateFilePath(id, sibling); + replacementPaths[id] = sibling; pathById[id] = sibling; return MapEntry(id, true); } @@ -853,27 +955,133 @@ class DownloadHistoryNotifier extends Notifier { } } - if (orphanedIds.isEmpty) { + return ( + orphanedIds: orphanedIds, + replacementPaths: replacementPaths, + pathById: pathById, + ); + } + + void _applyHistoryPathAndDeletionChanges({ + required List deletedIds, + required Map replacementPaths, + }) { + if (deletedIds.isEmpty && replacementPaths.isEmpty) { + return; + } + final deletedSet = deletedIds.toSet(); + final updatedItems = []; + 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 _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 cleanupOrphanedDownloads() async { + _historyLog.i('Starting orphaned downloads cleanup...'); + final orphanedIds = []; + final replacementPaths = {}; + 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'); return 0; } - final deletedCount = await _db.deleteByIds(orphanedIds); - - final orphanedSet = orphanedIds.toSet(); - state = state.copyWith( - items: state.items - .where((item) => !orphanedSet.contains(item.id)) - .toList(), + final deletedCount = orphanedIds.isEmpty + ? 0 + : await _db.deleteByIds(orphanedIds); + _applyHistoryPathAndDeletionChanges( + deletedIds: orphanedIds, + replacementPaths: replacementPaths, ); - _historyLog.i('Cleaned up $deletedCount orphaned entries'); + _historyLog.i( + 'Cleaned up $deletedCount orphaned entries and repaired ${replacementPaths.length} paths', + ); return deletedCount; } void clearHistory() { state = DownloadHistoryState(); - _db.clearAll().catchError((e) { + _db.clearAll().catchError((Object e) { _historyLog.e('Failed to clear database: $e'); }); } @@ -958,12 +1166,14 @@ class _ProgressUpdate { final double progress; final double? speedMBps; final int? bytesReceived; + final int? bytesTotal; const _ProgressUpdate({ required this.status, required this.progress, this.speedMBps, this.bytesReceived, + this.bytesTotal, }); } @@ -1379,6 +1589,7 @@ class DownloadQueueNotifier extends Notifier { progress: normalizedProgress, speedMBps: normalizedSpeed, bytesReceived: normalizedBytes, + bytesTotal: bytesTotal, ); if (LogBuffer.loggingEnabled) { @@ -1416,11 +1627,13 @@ class DownloadQueueNotifier extends Notifier { progress: update.progress, speedMBps: update.speedMBps ?? current.speedMBps, bytesReceived: update.bytesReceived ?? current.bytesReceived, + bytesTotal: update.bytesTotal ?? current.bytesTotal, ); if (current.status != next.status || current.progress != next.progress || current.speedMBps != next.speedMBps || - current.bytesReceived != next.bytesReceived) { + current.bytesReceived != next.bytesReceived || + current.bytesTotal != next.bytesTotal) { if (!changed) { updatedItems = List.from(updatedItems); changed = true; @@ -1700,6 +1913,20 @@ class DownloadQueueNotifier extends Notifier { } } + 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) { final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; await _ensureDirExists(singlesPath, label: 'Singles folder'); @@ -1858,6 +2085,14 @@ class DownloadQueueNotifier extends Notifier { return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); } + if (albumFolderStructure == 'artist_album_flat') { + if (isSingle) { + return _joinRelativePath(playlistPrefix, artistName); + } + final albumName = _sanitizeFolderName(track.albumName); + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); + } + if (isSingle) { return _joinRelativePath(playlistPrefix, 'Singles'); } @@ -1920,15 +2155,12 @@ class DownloadQueueNotifier extends Notifier { } 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') { return '.m4a'; } + final q = quality.toLowerCase(); + if (q.startsWith('opus')) return '.opus'; + if (q.startsWith('mp3')) return '.mp3'; return '.flac'; } @@ -2181,6 +2413,7 @@ class DownloadQueueNotifier extends Notifier { progress: 0, speedMBps: 0, bytesReceived: 0, + bytesTotal: 0, ); }) .toList(growable: false); @@ -2206,6 +2439,31 @@ class DownloadQueueNotifier extends Notifier { _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() { final items = state.items .where( @@ -2474,7 +2732,6 @@ class DownloadQueueNotifier extends Notifier { static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$'); String _upgradeToMaxQualityCover(String coverUrl) { - // Spotify CDN upgrade (hash-based size identifiers) const spotifySize300 = 'ab67616d00001e02'; const spotifySize640 = 'ab67616d0000b273'; const spotifySizeMax = 'ab67616d000082c1'; @@ -2487,7 +2744,6 @@ class DownloadQueueNotifier extends Notifier { result = result.replaceFirst(spotifySize640, spotifySizeMax); } - // Deezer CDN upgrade (1000x1000 → 1800x1800) if (result.contains('cdn-images.dzcdn.net')) { final upgraded = result.replaceFirst( _deezerSizeRegex, @@ -3168,7 +3424,6 @@ class DownloadQueueNotifier extends Notifier { Future _processQueue() async { if (state.isProcessing) return; - // Check network connectivity before starting final settings = ref.read(settingsProvider); updateSettings(settings); final isSafMode = _isSafMode(settings); @@ -3228,7 +3483,6 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: musicDir.path); ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path); } else if (!isValidIosWritablePath(state.outputDir)) { - // Check for other invalid paths (like container root without Documents/) _log.w( 'iOS: Invalid output path detected (container root?), falling back to app Documents folder', ); @@ -3250,7 +3504,6 @@ class DownloadQueueNotifier extends Notifier { _log.d('Output directory: ${state.outputDir}'); } else { _log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})'); - // Validate SAF permission is still accessible try { final testResult = await PlatformBridge.createSafFileFromPath( treeUri: settings.downloadTreeUri, @@ -3259,16 +3512,12 @@ class DownloadQueueNotifier extends Notifier { mimeType: 'application/octet-stream', 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) { - // Clean up test file await PlatformBridge.safDelete(testResult); } } catch (e) { _log.e('SAF permission validation failed: $e'); _log.w('SAF tree URI may be invalid or permission revoked'); - // Mark all queued items as failed for (final item in state.items) { if (item.status == DownloadStatus.queued) { updateItemStatus( @@ -3359,7 +3608,7 @@ class DownloadQueueNotifier extends Notifier { _log.d('Queue is paused, waiting for active downloads...'); await Future.any([ Future.wait(activeDownloads.values), - Future.delayed(_queueSchedulingInterval), + Future.delayed(_queueSchedulingInterval), ]); continue; } @@ -3402,14 +3651,12 @@ class DownloadQueueNotifier extends Notifier { } 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([ Future.any(activeDownloads.values), - Future.delayed(_queueSchedulingInterval), + Future.delayed(_queueSchedulingInterval), ]); } else { - await Future.delayed(_queueSchedulingInterval); + await Future.delayed(_queueSchedulingInterval); } } @@ -3555,28 +3802,6 @@ class DownloadQueueNotifier extends Notifier { ); 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 relativeOutputDir = isSafMode ? await _buildRelativeOutputDir( @@ -3684,13 +3909,101 @@ class DownloadQueueNotifier extends Notifier { } } - // 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?; + 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 && deezerTrackId == null && !shouldSkipExtensionSongLinkPrelookup && trackToDownload.id.isNotEmpty && !trackToDownload.id.startsWith('deezer:') && - !trackToDownload.id.startsWith('extension:')) { + !trackToDownload.id.startsWith('extension:') && + !trackToDownload.id.startsWith('tidal:') && + !trackToDownload.id.startsWith('qobuz:')) { try { String spotifyId = trackToDownload.id; if (spotifyId.startsWith('spotify:track:')) { @@ -3703,7 +4016,6 @@ class DownloadQueueNotifier extends Notifier { 'track', spotifyId, ); - // Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}} final trackData = deezerData['track']; if (trackData is Map) { final rawId = trackData['spotify_id'] as String?; @@ -3839,14 +4151,10 @@ class DownloadQueueNotifier extends Notifier { final relativeDir = useSaf ? outputDir : ''; final fileName = useSaf ? (safFileName ?? '') : ''; final outputExt = useSaf ? safOutputExt : ''; - final isYouTube = item.service == 'youtube'; - final shouldUseExtensions = !isYouTube && useExtensions; - final shouldUseFallback = !isYouTube && state.autoFallback; + final shouldUseExtensions = useExtensions; + final shouldUseFallback = state.autoFallback; - if (isYouTube) { - _log.d('Using YouTube/Cobalt provider for download'); - _log.d('Quality: $quality (lossy only)'); - } else if (shouldUseExtensions) { + if (shouldUseExtensions) { _log.d('Using extension providers for download'); _log.d( 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', @@ -4013,7 +4321,6 @@ class DownloadQueueNotifier extends Notifier { finalSafFileName = reportedFileName; } - // Check if file already existed (detected via ISRC match in Go backend) final wasExisting = result['already_exists'] == true; if (wasExisting) { _log.i('File already exists in library: $filePath'); @@ -4026,7 +4333,6 @@ class DownloadQueueNotifier extends Notifier { String actualQuality = quality; if (actualBitDepth != null && actualBitDepth > 0) { - // Format: "24-bit/96kHz" or "16-bit/44.1kHz" final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0 ? (actualSampleRate / 1000).toStringAsFixed( actualSampleRate % 1000 == 0 ? 0 : 1, @@ -4182,7 +4488,6 @@ class DownloadQueueNotifier extends Notifier { } if (isM4aFile || shouldForceTidalSafM4aHandling) { - // At this point filePath is guaranteed non-null by the checks above. final currentFilePath = filePath; if (isContentUriPath && effectiveSafMode) { @@ -4521,11 +4826,23 @@ class DownloadQueueNotifier extends Notifier { } else if (metadataEmbeddingEnabled && isContentUriPath && effectiveSafMode && - isFlacFile && + !isM4aFile && !wasExisting) { 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( - '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); if (tempPath != null) { @@ -4545,21 +4862,39 @@ class DownloadQueueNotifier extends Notifier { final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; - await _embedMetadataAndCover( - tempPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - writeExternalLrc: false, - ); + if (isMp3File) { + await _embedMetadataToMp3( + tempPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + 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( treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), + mimeType: _mimeTypeForExt(ext), srcPath: tempPath, ); @@ -4569,12 +4904,14 @@ class DownloadQueueNotifier extends Notifier { } filePath = newUri; finalSafFileName = newFileName; - _log.d('SAF FLAC metadata embedding completed'); + _log.d('SAF $formatName metadata embedding completed'); } 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) { - _log.w('SAF FLAC metadata embedding failed: $e'); + _log.w('SAF $formatName metadata embedding failed: $e'); } finally { try { await File(tempPath).delete(); @@ -4619,109 +4956,6 @@ class DownloadQueueNotifier extends Notifier { } } - // 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); if (itemAfterDownload == null || _isLocallyCancelled(item.id, item: itemAfterDownload)) { @@ -4746,9 +4980,6 @@ class DownloadQueueNotifier extends Notifier { 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 && filePath != null && filePath.isNotEmpty && @@ -5063,8 +5294,6 @@ class DownloadQueueNotifier extends Notifier { ); _failedInSession++; - // Immediately cleanup connections after failure to prevent - // poisoned connection pool from affecting subsequent downloads try { await PlatformBridge.cleanupConnections(); } catch (e) { @@ -5117,7 +5346,6 @@ class DownloadQueueNotifier extends Notifier { ); _failedInSession++; - // Immediately cleanup connections after exception try { await PlatformBridge.cleanupConnections(); } catch (cleanupErr) { diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index e4ffa5f7..f55892fb 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -11,7 +11,7 @@ final _log = AppLogger('ExploreProvider'); class ExploreItem { final String id; final String uri; - final String type; // track, album, playlist, artist, station + final String type; final String name; final String artists; final String? description; @@ -168,7 +168,6 @@ class ExploreNotifier extends Notifier { return const ExploreState(); } - /// Restore cached home feed from SharedPreferences immediately on startup Future _restoreFromCache() async { try { final prefs = await SharedPreferences.getInstance(); @@ -199,7 +198,6 @@ class ExploreNotifier extends Notifier { } } - /// Save home feed to SharedPreferences for instant restore on next launch Future _saveToCache(List sections) async { try { final prefs = await SharedPreferences.getInstance(); @@ -212,11 +210,9 @@ class ExploreNotifier extends Notifier { } } - /// Fetch home feed from spotify-web extension Future fetchHomeFeed({bool forceRefresh = false}) async { _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); - // If we have cached content and it's fresh enough, skip network fetch if (!forceRefresh && state.hasContent && state.lastFetched != null && @@ -230,7 +226,6 @@ class ExploreNotifier extends Notifier { return; } - // Only show loading spinner if we have no cached content to display final showLoading = !state.hasContent; state = state.copyWith(isLoading: showLoading, error: null); @@ -247,14 +242,12 @@ class ExploreNotifier extends Notifier { if (!extension.enabled || !extension.hasHomeFeed) { continue; } - // If user has a preference, use that if (preferredId != null && preferredId.isNotEmpty && extension.id == preferredId) { targetExt = extension; break; } - // Otherwise take the first available (fallback to spotify-web if found) if (targetExt == null || extension.id == 'spotify-web') { targetExt = extension; if (preferredId == null && extension.id == 'spotify-web') { @@ -317,7 +310,6 @@ class ExploreNotifier extends Notifier { lastFetched: DateTime.now(), ); - // Save to disk cache for instant restore on next app launch _saveToCache(sections); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 43f25997..3ea5ba7a 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -32,14 +32,12 @@ class Extension { final bool hasMetadataProvider; final bool hasDownloadProvider; final bool hasLyricsProvider; - final bool - skipMetadataEnrichment; // If true, use metadata from extension instead of enriching + final bool skipMetadataEnrichment; final SearchBehavior? searchBehavior; final URLHandler? urlHandler; final TrackMatching? trackMatching; final PostProcessing? postProcessing; - final Map - capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) + final Map capabilities; const Extension({ required this.id, @@ -198,12 +196,10 @@ class SearchBehavior { final String? placeholder; final bool primary; final String? icon; - final String? - thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) + final String? thumbnailRatio; final int? thumbnailWidth; final int? thumbnailHeight; - final List - filters; // Available search filters (e.g., track, album, artist, playlist) + final List filters; const SearchBehavior({ required this.enabled, @@ -239,11 +235,11 @@ class SearchBehavior { } switch (thumbnailRatio) { - case 'wide': // 16:9 - YouTube style + case 'wide': return (defaultSize * 16 / 9, defaultSize); - case 'portrait': // 2:3 - Poster style + case 'portrait': return (defaultSize * 2 / 3, defaultSize); - case 'square': // 1:1 - Album art style + case 'square': default: return (defaultSize, defaultSize); } @@ -290,7 +286,6 @@ class PostProcessing { } } -/// URL handler configuration for custom URL patterns class URLHandler { final bool enabled; final List patterns; @@ -304,7 +299,6 @@ class URLHandler { ); } - /// Check if a URL matches any of the patterns bool matchesURL(String url) { if (!enabled || patterns.isEmpty) return false; final lowerUrl = url.toLowerCase(); diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index a7a3e373..d6fece8e 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -118,7 +118,7 @@ class UserPlaylistCollection { createdAt: createdAt, updatedAt: updatedAt, tracks: tracksRaw - .whereType() + .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) @@ -233,19 +233,19 @@ class LibraryCollectionsState { return LibraryCollectionsState( wishlist: wishlistRaw - .whereType() + .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) .toList(growable: false), loved: lovedRaw - .whereType() + .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) .toList(growable: false), playlists: playlistsRaw - .whereType() + .whereType>() .map( (e) => UserPlaylistCollection.fromJson(Map.from(e)), @@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier { final destPath = p.join(coversDir.path, '$playlistId$ext'); if (playlist.coverImagePath == destPath) return; - // Copy image to persistent location await File(sourceFilePath).copy(destPath); final now = DateTime.now(); @@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier { final playlist = state.playlistById(playlistId); if (playlist == null || playlist.coverImagePath == null) return; - // Delete the file if it exists final path = playlist.coverImagePath; if (path != null) { final file = File(path); diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index ab5416c7..3e90f0fc 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -252,8 +252,6 @@ class LocalLibraryNotifier extends Notifier { _startProgressPolling(); - // On iOS, start accessing the security-scoped bookmark so the Go backend - // can read files outside the app sandbox. String? resolvedPath; bool didStartSecurityAccess = false; if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { @@ -275,9 +273,6 @@ class LocalLibraryNotifier extends Notifier { try { final isSaf = effectiveFolderPath.startsWith('content://'); - // Get all file paths from download history to exclude them. - // Merge DB + in-memory state to avoid race when a fresh download has not - // been flushed to SQLite yet. final downloadedPaths = await _historyDb.getAllFilePaths(); final inMemoryHistoryPaths = ref .read(downloadHistoryProvider) @@ -298,7 +293,6 @@ class LocalLibraryNotifier extends Notifier { ); if (forceFullScan) { - // Full scan path - ignores existing data final results = isSaf ? await PlatformBridge.scanSafTree(effectiveFolderPath) : await PlatformBridge.scanLibraryFolder(effectiveFolderPath); @@ -324,16 +318,8 @@ class LocalLibraryNotifier extends Notifier { _log.i('Skipped $skippedDownloads files already in download history'); } - // Full scan should replace library index entirely. - await _db.clearAll(); - if (items.isNotEmpty) { - await _db.upsertBatch(items.map((e) => e.toJson()).toList()); - } - final persistedItems = - (await _db.getAll()) - .map(LocalLibraryItem.fromJson) - .toList(growable: false) - ..sort(_compareLibraryItems); + await _db.replaceAll(items.map((e) => e.toJson()).toList()); + final persistedItems = [...items]..sort(_compareLibraryItems); final now = DateTime.now(); try { @@ -364,7 +350,6 @@ class LocalLibraryNotifier extends Notifier { errorCount: state.scanErrorCount, ); } else { - // Incremental scan path - only scans new/modified files final existingFiles = await _db.getFileModTimes(); _log.i( 'Incremental scan: ${existingFiles.length} existing files in database', @@ -423,7 +408,6 @@ class LocalLibraryNotifier extends Notifier { return; } - // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' final scannedList = (result['files'] as List?) ?? (result['scanned'] as List?) ?? @@ -444,10 +428,6 @@ class LocalLibraryNotifier extends Notifier { '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', ); - // Build the incremental merge base from SQLite, not the current - // provider state. Startup auto-scan can fire before `state.items` has - // finished loading, which would otherwise drop unchanged rows from the - // in-memory library until a manual full rescan. final existingJson = await _db.getAll(); final currentByPath = { for (final item in existingJson.map(LocalLibraryItem.fromJson)) @@ -468,7 +448,6 @@ class LocalLibraryNotifier extends Notifier { ); } - // Upsert new/modified items (excluding downloaded files) final updatedItems = []; int skippedDownloads = existingDownloadedPaths.length; if (scannedList.isNotEmpty) { @@ -502,11 +481,8 @@ class LocalLibraryNotifier extends Notifier { _log.i('Deleted $deleteCount items from database'); } - final items = - (await _db.getAll()) - .map(LocalLibraryItem.fromJson) - .toList(growable: false) - ..sort(_compareLibraryItems); + final items = currentByPath.values.toList(growable: false) + ..sort(_compareLibraryItems); final now = DateTime.now(); try { diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index 7b9cc15a..af5e4387 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart'; const _maxRecentItems = 20; -/// Types of items that can be accessed enum RecentAccessType { artist, album, track, playlist } -/// Represents a recently accessed item class RecentAccessItem { final String id; final String name; - final String? subtitle; // Artist name for tracks/albums, null for artists + final String? subtitle; final String? imageUrl; final RecentAccessType type; final DateTime accessedAt; - final String? providerId; // Extension ID or 'deezer' for built-in + final String? providerId; const RecentAccessItem({ required this.id, @@ -53,7 +51,6 @@ class RecentAccessItem { ); } - /// Create a unique key for deduplication String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id'; @override @@ -67,7 +64,6 @@ class RecentAccessItem { int get hashCode => uniqueKey.hashCode; } -/// State for recent access history class RecentAccessState { final List items; final Set hiddenDownloadIds; @@ -92,7 +88,6 @@ class RecentAccessState { } } -/// Provider for managing recent access history class RecentAccessNotifier extends Notifier { final AppStateDatabase _appStateDb = AppStateDatabase.instance; @@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier { } } - /// Record an access to an artist void recordArtistAccess({ required String id, required String name, @@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier { ); } - /// Record an access to an album void recordAlbumAccess({ required String id, required String name, @@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier { ); } - /// Record an access to a track void recordTrackAccess({ required String id, required String name, @@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier { ); } - /// Record an access to a playlist void recordPlaylistAccess({ required String id, required String name, @@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier { } } - /// Remove a specific item from history void removeItem(RecentAccessItem item) { final updatedItems = state.items .where((e) => e.uniqueKey != item.uniqueKey) @@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier { unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey)); } - /// Hide a download item from recents (without deleting the actual download) void hideDownloadFromRecents(String downloadId) { final updatedHidden = {...state.hiddenDownloadIds, downloadId}; state = state.copyWith(hiddenDownloadIds: updatedHidden); unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId)); } - /// Check if a download is hidden from recents bool isDownloadHidden(String downloadId) { return state.hiddenDownloadIds.contains(downloadId); } - /// Clear all history void clearHistory() { state = state.copyWith(items: []); unawaited(_appStateDb.clearRecentAccessRows()); } - /// Clear hidden downloads (show all again) void clearHiddenDownloads() { state = state.copyWith(hiddenDownloadIds: {}); unawaited(_appStateDb.clearHiddenRecentDownloadIds()); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f2309d5d..832ecff5 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -11,13 +11,11 @@ import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 6; +const _currentMigrationVersion = 7; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { - static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; - static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); final Future _prefs = SharedPreferences.getInstance(); @@ -36,11 +34,12 @@ class SettingsNotifier extends Notifier { final prefs = await _prefs; final json = prefs.getString(_settingsKey); if (json != null) { - state = AppSettings.fromJson(jsonDecode(json)); + state = AppSettings.fromJson( + Map.from(jsonDecode(json) as Map), + ); await _runMigrations(prefs); await _normalizeIosDownloadDirectoryIfNeeded(); - await _normalizeYouTubeBitratesIfNeeded(); await _normalizeSongLinkRegionIfNeeded(); } @@ -55,7 +54,9 @@ class SettingsNotifier extends Notifier { void _syncLyricsSettingsToBackend() { if (!PlatformBridge.supportsCoreBackend) return; - PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) { + PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError(( + Object e, + ) { _log.w('Failed to sync lyrics providers to backend: $e'); }); @@ -64,7 +65,7 @@ class SettingsNotifier extends Notifier { 'include_romanization_netease': state.lyricsIncludeRomanizationNetease, 'multi_person_word_by_word': state.lyricsMultiPersonWordByWord, 'musixmatch_language': state.musixmatchLanguage, - }).catchError((e) { + }).catchError((Object e) { _log.w('Failed to sync lyrics fetch options to backend: $e'); }); } @@ -76,7 +77,7 @@ class SettingsNotifier extends Notifier { PlatformBridge.setNetworkCompatibilityOptions( allowHttp: compatibilityMode, insecureTls: compatibilityMode, - ).catchError((e) { + ).catchError((Object e) { _log.w('Failed to sync network compatibility options to backend: $e'); }); } @@ -122,6 +123,10 @@ class SettingsNotifier extends Notifier { ); } state = state.copyWith(lastSeenVersion: AppInfo.version); + // Migration 7: YouTube is no longer a built-in service — reset to Tidal + if (state.defaultService == 'youtube') { + state = state.copyWith(defaultService: 'tidal'); + } await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); } @@ -153,49 +158,6 @@ class SettingsNotifier extends Notifier { } } - int _nearestSupportedBitrate(int value, List 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 _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 _normalizeIosDownloadDirectoryIfNeeded() async { if (!Platform.isIOS) return; @@ -469,18 +431,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setYoutubeOpusBitrate(int bitrate) { - final normalized = _normalizeYouTubeOpusBitrate(bitrate); - state = state.copyWith(youtubeOpusBitrate: normalized); - _saveSettings(); - } - - void setYoutubeMp3Bitrate(int bitrate) { - final normalized = _normalizeYouTubeMp3Bitrate(bitrate); - state = state.copyWith(youtubeMp3Bitrate: normalized); - _saveSettings(); - } - void setUseAllFilesAccess(bool enabled) { state = state.copyWith(useAllFilesAccess: enabled); _saveSettings(); diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index bac55c5c..e6fc5eec 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url'; int compareVersions(String v1, String v2) { final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.'); final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.'); - + final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; - + for (var i = 0; i < maxLen; i++) { final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0; final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0; - + if (n1 < n2) return -1; if (n1 > n2) return 1; } @@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) { } class StoreCategory { - static const String metadata = 'metadata'; static const String download = 'download'; static const String utility = 'utility'; static const String lyrics = 'lyrics'; static const String integration = 'integration'; - static const List all = [metadata, download, utility, lyrics, integration]; + static const List all = [ + metadata, + download, + utility, + lyrics, + integration, + ]; static String getDisplayName(String category) { switch (category) { @@ -94,7 +99,8 @@ class StoreExtension { return StoreExtension( id: json['id'] as String? ?? '', name: json['name'] as String? ?? '', - displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', + displayName: + json['display_name'] as String? ?? json['name'] as String? ?? '', version: json['version'] as String? ?? '0.0.0', author: json['author'] as String? ?? 'Unknown', description: json['description'] as String? ?? '', @@ -117,7 +123,6 @@ class StoreExtension { } } - class StoreState { final List extensions; final String? selectedCategory; @@ -160,11 +165,15 @@ class StoreState { }) { return StoreState( extensions: extensions ?? this.extensions, - selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory), + selectedCategory: clearCategory + ? null + : (selectedCategory ?? this.selectedCategory), searchQuery: searchQuery ?? this.searchQuery, isLoading: isLoading ?? this.isLoading, isDownloading: isDownloading ?? this.isDownloading, - downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId), + downloadingId: clearDownloadingId + ? null + : (downloadingId ?? this.downloadingId), error: clearError ? null : (error ?? this.error), isInitialized: isInitialized ?? this.isInitialized, registryUrl: registryUrl ?? this.registryUrl, @@ -180,13 +189,16 @@ class StoreState { if (searchQuery.isNotEmpty) { final query = searchQuery.toLowerCase(); - result = result.where((e) => - e.name.toLowerCase().contains(query) || - e.displayName.toLowerCase().contains(query) || - e.description.toLowerCase().contains(query) || - e.author.toLowerCase().contains(query) || - e.tags.any((t) => t.toLowerCase().contains(query)) - ).toList(); + result = result + .where( + (e) => + e.name.toLowerCase().contains(query) || + e.displayName.toLowerCase().contains(query) || + e.description.toLowerCase().contains(query) || + e.author.toLowerCase().contains(query) || + e.tags.any((t) => t.toLowerCase().contains(query)), + ) + .toList(); } return result; @@ -206,23 +218,28 @@ class StoreNotifier extends Notifier { Future initialize(String cacheDir) async { if (state.isInitialized) return; - state = state.copyWith(isLoading: true, clearError: true); + // Load saved registry URL early to avoid UI flash (empty → setup screen) + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString(_registryUrlPrefKey) ?? ''; + + state = state.copyWith( + isLoading: true, + clearError: true, + registryUrl: savedUrl, + ); try { await PlatformBridge.initExtensionStore(cacheDir); - // Load saved registry URL from SharedPreferences - final prefs = await SharedPreferences.getInstance(); - final savedUrl = prefs.getString(_registryUrlPrefKey) ?? ''; - if (savedUrl.isNotEmpty) { await PlatformBridge.setStoreRegistryUrl(savedUrl); - state = state.copyWith(registryUrl: savedUrl); await refresh(); } state = state.copyWith(isInitialized: true, isLoading: false); - _log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})'); + _log.i( + 'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})', + ); } catch (e) { _log.e('Failed to initialize store: $e'); state = state.copyWith(isLoading: false, error: e.toString()); @@ -247,13 +264,12 @@ class StoreNotifier extends Notifier { // Read back the resolved URL (may differ from input after normalisation). final resolvedUrl = await PlatformBridge.getStoreRegistryUrl(); - // Persist to SharedPreferences final prefs = await SharedPreferences.getInstance(); await prefs.setString(_registryUrlPrefKey, resolvedUrl); state = state.copyWith( registryUrl: resolvedUrl, - extensions: const [], // Clear old extensions + extensions: const [], ); _log.i('Registry URL set to: $resolvedUrl'); @@ -292,7 +308,9 @@ class StoreNotifier extends Notifier { state = state.copyWith(isLoading: true, clearError: true); try { - final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh); + final extensions = await PlatformBridge.getStoreExtensions( + forceRefresh: forceRefresh, + ); state = state.copyWith( extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(), isLoading: false, @@ -320,12 +338,23 @@ class StoreNotifier extends Notifier { state = state.copyWith(searchQuery: '', clearCategory: true); } - Future installExtension(String extensionId, String tempDir, String extensionsDir) async { - state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); + Future installExtension( + String extensionId, + String tempDir, + String extensionsDir, + ) async { + state = state.copyWith( + isDownloading: true, + downloadingId: extensionId, + clearError: true, + ); try { _log.i('Downloading extension: $extensionId'); - final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); + final downloadPath = await PlatformBridge.downloadStoreExtension( + extensionId, + tempDir, + ); _log.i('Installing extension from: $downloadPath'); final extNotifier = ref.read(extensionProvider.notifier); @@ -340,18 +369,28 @@ class StoreNotifier extends Notifier { return success; } catch (e) { _log.e('Failed to install extension: $e'); - state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); + state = state.copyWith( + isDownloading: false, + clearDownloadingId: true, + error: e.toString(), + ); return false; } } - Future updateExtension(String extensionId, String tempDir) async { - state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); + state = state.copyWith( + isDownloading: true, + downloadingId: extensionId, + clearError: true, + ); try { _log.i('Downloading update for: $extensionId'); - final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); + final downloadPath = await PlatformBridge.downloadStoreExtension( + extensionId, + tempDir, + ); _log.i('Upgrading extension from: $downloadPath'); final extNotifier = ref.read(extensionProvider.notifier); @@ -366,7 +405,11 @@ class StoreNotifier extends Notifier { return success; } catch (e) { _log.e('Failed to update extension: $e'); - state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); + state = state.copyWith( + isDownloading: false, + clearDownloadingId: true, + error: e.toString(), + ); return false; } } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index ad95b21b..0ed2c9e0 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier { await _saveToStorage(); } - /// Set custom seed color (used when dynamic color is disabled) Future setSeedColor(Color color) async { state = state.copyWith(seedColorValue: color.toARGB32()); await _saveToStorage(); @@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier { ); } } - diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 5de7d16b..20780c49 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -18,21 +18,18 @@ class TrackState { final String? artistId; final String? artistName; final String? coverUrl; - final String? headerImageUrl; // Artist header image for background + final String? headerImageUrl; final int? monthlyListeners; - final List? artistAlbums; // For artist page - final List? artistTopTracks; // Artist's popular tracks - final List? searchArtists; // For search results - final List? searchAlbums; // For search results (albums) - final List? searchPlaylists; // For search results (playlists) - final bool hasSearchText; // For back button handling - final bool isShowingRecentAccess; // For recent access mode - final String? - searchExtensionId; // Extension ID used for current search results - final String? - selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") - final String? - searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz") + final List? artistAlbums; + final List? artistTopTracks; + final List? searchArtists; + final List? searchAlbums; + final List? searchPlaylists; + final bool hasSearchText; + final bool isShowingRecentAccess; + final String? searchExtensionId; + final String? selectedSearchFilter; + final String? searchSource; const TrackState({ this.tracks = const [], @@ -127,9 +124,9 @@ class ArtistAlbum { final String releaseDate; final int totalTracks; final String? coverUrl; - final String albumType; // album, single, compilation + final String albumType; final String artists; - final String? providerId; // Extension ID if from extension + final String? providerId; const ArtistAlbum({ required this.id, @@ -204,7 +201,6 @@ class TrackNotifier extends Notifier { return const TrackState(); } - /// Check if request is still valid (not cancelled by newer request) bool _isRequestValid(int requestId) => requestId == _currentRequestId; Future fetchFromUrl(String url, {bool useDeezerFallback = true}) async { @@ -217,7 +213,6 @@ class TrackNotifier extends Notifier { if (extensionHandler != null) { _log.i('Found extension URL handler: $extensionHandler for URL: $url'); - // Retry logic for extension URL handlers (up to 3 attempts) Map? result; for (int attempt = 1; attempt <= 3; attempt++) { result = await PlatformBridge.handleURLWithExtension(url); @@ -239,7 +234,7 @@ class TrackNotifier extends Notifier { } if (attempt < 3) { - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); } } @@ -280,10 +275,12 @@ class TrackNotifier extends Notifier { state = TrackState( tracks: tracks, isLoading: false, - albumId: result['album']?['id'] as String?, + albumId: + (result['album'] as Map?)?['id'] as String?, albumName: result['name'] as String? ?? - result['album']?['name'] as String?, + (result['album'] as Map?)?['name'] + as String?, playlistName: type == 'playlist' ? result['name'] as String? : null, @@ -541,7 +538,6 @@ class TrackNotifier extends Notifier { return; } - // If URL doesn't match any known service, it's unrecognized final isSpotifyUrl = url.contains('open.spotify.com') || url.contains('spotify.link') || @@ -643,7 +639,6 @@ class TrackNotifier extends Notifier { }) async { final requestId = ++_currentRequestId; - // Preserve selected filter during loading final currentFilter = filterOverride ?? state.selectedSearchFilter; state = TrackState( @@ -662,7 +657,6 @@ class TrackNotifier extends Notifier { final includeExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions; - // Determine the effective search provider final effectiveProvider = builtInSearchProvider ?? 'deezer'; _log.i( @@ -672,7 +666,6 @@ class TrackNotifier extends Notifier { Map results; List> metadataTrackResults = []; - // Only use metadata providers for Deezer search (default behavior) if (effectiveProvider == 'deezer') { try { _log.d('Calling metadata provider search API...'); @@ -692,7 +685,6 @@ class TrackNotifier extends Notifier { } } - // Call the appropriate search API switch (effectiveProvider) { case 'tidal': _log.d('Calling Tidal search API...'); @@ -808,9 +800,8 @@ class TrackNotifier extends Notifier { isLoading: false, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, - selectedSearchFilter: currentFilter, // Preserve filter in results - searchSource: - effectiveProvider, // Track which service was used for search + selectedSearchFilter: currentFilter, + searchSource: effectiveProvider, ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; @@ -836,8 +827,7 @@ class TrackNotifier extends Notifier { isLoading: true, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, - selectedSearchFilter: - state.selectedSearchFilter, // Preserve filter during loading + selectedSearchFilter: state.selectedSearchFilter, ); try { @@ -876,9 +866,8 @@ class TrackNotifier extends Notifier { isLoading: false, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, - searchExtensionId: extensionId, // Store which extension was used - selectedSearchFilter: - state.selectedSearchFilter, // Preserve selected filter + searchExtensionId: extensionId, + selectedSearchFilter: state.selectedSearchFilter, ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; @@ -933,16 +922,13 @@ class TrackNotifier extends Notifier { final tracks = List.from(state.tracks); tracks[index] = updatedTrack; state = state.copyWith(tracks: tracks); - } catch (_) { - // Silently ignore update failures - track may have been removed - } + } catch (_) {} } void clear() { state = const TrackState(); } - /// Set selected search filter for extension search void setSearchFilter(String? filter) { if (state.selectedSearchFilter == filter) return; state = state.copyWith( @@ -951,7 +937,6 @@ class TrackNotifier extends Notifier { ); } - /// Set search text state for back button handling void setSearchText(bool hasText) { if (state.hasSearchText == hasText) { return; @@ -966,7 +951,6 @@ class TrackNotifier extends Notifier { state = state.copyWith(isShowingRecentAccess: showing); } - /// Set tracks from a collection (album/playlist) opened from search results void setTracksFromCollection({ required List tracks, String? albumName, @@ -1127,7 +1111,7 @@ class TrackNotifier extends Notifier { 'isrc': isrc, 'track_name': track.name, 'artist_name': track.artistName, - 'spotify_id': track.id, // Include Spotify ID for Amazon lookup + 'spotify_id': track.id, 'service': 'tidal', }); if (cacheRequests.length >= _maxPreWarmTracksPerRequest) { diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 3f020953..888cc613 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -241,6 +242,16 @@ class _AlbumScreenState extends ConsumerState { ); } + String? _recommendedDownloadService() { + if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { + return widget.extensionId; + } + if (widget.albumId.startsWith('tidal:')) return 'tidal'; + if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; + if (widget.albumId.startsWith('deezer:')) return 'deezer'; + return null; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -257,8 +268,8 @@ class _AlbumScreenState extends ConsumerState { if (_isLoading) const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: AlbumTrackListSkeleton(itemCount: 10), ), ), if (_error != null) @@ -534,9 +545,12 @@ class _AlbumScreenState extends ConsumerState { final track = tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _AlbumTrackItem( - track: track, - onDownload: () => _downloadTrack(context, track), + child: StaggeredListItem( + index: index, + child: _AlbumTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), ), ); }, childCount: tracks.length), @@ -551,6 +565,7 @@ class _AlbumScreenState extends ConsumerState { trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) @@ -576,7 +591,6 @@ class _AlbumScreenState extends ConsumerState { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; - // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = @@ -623,6 +637,7 @@ class _AlbumScreenState extends ConsumerState { context, trackName: '${tracksToQueue.length} tracks', artistName: widget.albumName, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 7aefcd64..1267b694 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class _ArtistCache { @@ -152,6 +153,16 @@ class _ArtistScreenState extends ConsumerState { return tileSize + 64 + ((textScale - 1) * 14); } + String? _recommendedDownloadService() { + if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { + return widget.extensionId; + } + if (widget.artistId.startsWith('tidal:')) return 'tidal'; + if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; + if (widget.artistId.startsWith('deezer:')) return 'deezer'; + return null; + } + @override void initState() { super.initState(); @@ -481,10 +492,17 @@ class _ArtistScreenState extends ConsumerState { hasDiscography: hasDiscography, ), if (_isLoadingDiscography) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + SliverToBoxAdapter( + child: ArtistScreenSkeleton( + showCoverHeader: + (_headerImageUrl ?? + widget.headerImageUrl ?? + widget.coverUrl) == + null, + showPopularSection: + !widget.artistId.startsWith('deezer:') && + !widget.artistId.startsWith('qobuz:') && + !widget.artistId.startsWith('tidal:'), ), ), if (_error != null) @@ -787,7 +805,7 @@ class _ArtistScreenState extends ConsumerState { ); final singleTracks = singles.fold(0, (sum, a) => sum + a.totalTracks); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -889,6 +907,7 @@ class _ArtistScreenState extends ConsumerState { if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { _fetchAndQueueAlbums(albums, service, quality); }, @@ -920,7 +939,7 @@ class _ArtistScreenState extends ConsumerState { return; } - showDialog( + showDialog( context: context, barrierDismissible: false, builder: (ctx) => _FetchingProgressDialog( @@ -948,7 +967,6 @@ class _ArtistScreenState extends ConsumerState { fetchedCount++; - // Update progress dialog if (mounted) { _FetchingProgressDialog.updateProgress( context, @@ -979,7 +997,6 @@ class _ArtistScreenState extends ConsumerState { return; } - // Check which tracks are already downloaded final historyState = ref.read(downloadHistoryProvider); final tracksToQueue = []; int skippedCount = 0; @@ -1030,10 +1047,7 @@ class _ArtistScreenState extends ConsumerState { content: Text(message), action: SnackBarAction( label: context.l10n.snackbarViewQueue, - onPressed: () { - // Navigate to queue tab (index 1) - // This will be handled by the navigation system - }, + onPressed: () {}, ), ), ); @@ -1107,6 +1121,10 @@ class _ArtistScreenState extends ConsumerState { Track _parseTrackFromDeezer(Map data, ArtistAlbum album) { int durationMs = 0; final durationValue = data['duration']; + final artistData = data['artist']; + final artistName = artistData is Map + ? (artistData['name'] as String? ?? widget.artistName) + : (artistData?.toString() ?? widget.artistName); if (durationValue is int) { durationMs = durationValue * 1000; // Deezer returns seconds } else if (durationValue is double) { @@ -1116,9 +1134,7 @@ class _ArtistScreenState extends ConsumerState { return Track( id: 'deezer:${data['id']}', name: (data['title'] ?? data['name'] ?? '').toString(), - artistName: - (data['artist']?['name'] ?? data['artist'] ?? widget.artistName) - .toString(), + artistName: artistName, albumName: album.name, albumArtist: widget.artistName, artistId: widget.artistId, @@ -1154,6 +1170,8 @@ class _ArtistScreenState extends ConsumerState { imageUrl.isNotEmpty && Uri.tryParse(imageUrl)?.hasAuthority == true; + final isDark = Theme.of(context).brightness == Brightness.dark; + String? listenersText; final listeners = _monthlyListeners ?? widget.monthlyListeners; if (listeners != null && listeners > 0) { @@ -1224,7 +1242,9 @@ class _ArtistScreenState extends ConsumerState { Colors.transparent, Colors.black.withValues(alpha: 0.3), Colors.black.withValues(alpha: 0.7), - colorScheme.surface, + isDark + ? colorScheme.surface + : Colors.black.withValues(alpha: 0.85), ], stops: const [0.0, 0.5, 0.75, 1.0], ), @@ -1265,7 +1285,7 @@ class _ArtistScreenState extends ConsumerState { listenersText, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( - color: Colors.white.withValues(alpha: 0.8), + color: Colors.white, shadows: [ Shadow( offset: const Offset(0, 1), @@ -1689,6 +1709,7 @@ class _ArtistScreenState extends ConsumerState { if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { if (!mounted) return; enqueue(service, quality: quality); @@ -1839,29 +1860,14 @@ class _ArtistScreenState extends ConsumerState { Positioned( top: 8, right: 8, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 28, - height: 28, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.9), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 28, + unselectedColor: colorScheme.surface.withValues( + alpha: 0.9, ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 18, - ) - : null, ), ), if (showTypeBadge) @@ -1934,7 +1940,7 @@ class _ArtistScreenState extends ConsumerState { if (album.providerId != null && album.providerId!.isNotEmpty) { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: album.providerId!, albumId: album.id, @@ -1946,7 +1952,7 @@ class _ArtistScreenState extends ConsumerState { } else { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => AlbumScreen( albumId: album.id, albumName: album.name, @@ -2070,7 +2076,6 @@ class _FetchingProgressDialog extends StatefulWidget { required this.onCancel, }); - // Static method to update progress from outside static void updateProgress(BuildContext context, int current, int total) { final state = context .findAncestorStateOfType<_FetchingProgressDialogState>(); @@ -2143,7 +2148,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { ), ), const SizedBox(height: 8), - // Progress bar ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 0d40173a..3a35dee0 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { final tracks = allItems.where((item) { - // Use albumArtist if available and not empty, otherwise artistName final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) ? item.albumArtist! @@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; return itemKey == _albumLookupKey; }).toList()..sort((a, b) { - // Sort by disc number first, then by track number final aDisc = a.discNumber ?? 1; final bDisc = b.discNumber ?? 1; if (aDisc != bDisc) return aDisc.compareTo(bDisc); @@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { final track = tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: index, + child: _buildTrackItem(context, colorScheme, track), + ), ); }, childCount: tracks.length), ); @@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final discNumbers = _getSortedDiscNumbers(tracks); final List children = []; + var revealIndex = 0; for (final discNumber in discNumbers) { final discTracks = discMap[discNumber]; @@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { children.add( KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: revealIndex++, + child: _buildTrackItem(context, colorScheme, track), + ), ), ); } @@ -796,28 +795,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], @@ -950,7 +932,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( @@ -1123,7 +1105,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ? 'Opus' : null; if (ext == null || ext == targetFormat) continue; - // Skip lossy sources when target is lossless (pointless re-encoding) final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; if (isLosslessTarget && !isLosslessSource) continue; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index ca022604..5483bc7d 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -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/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class HomeTab extends ConsumerStatefulWidget { @@ -83,6 +84,18 @@ class _SearchResultBuckets { }); } +enum _SearchSortOption { + defaultOrder, + titleAsc, + titleDesc, + artistAsc, + artistDesc, + durationAsc, + durationDesc, + dateAsc, + dateDesc, +} + const _homeHistoryPreviewLimit = 48; class _HomeHistoryPreview { @@ -244,6 +257,7 @@ class _HomeTabState extends ConsumerState Map? _thumbnailSizesCache; List? _searchBucketsSourceTracks; _SearchResultBuckets? _searchBucketsCache; + _SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder; double _responsiveScale({ required BuildContext context, @@ -280,13 +294,13 @@ class _HomeTabState extends ConsumerState double _exploreCardSize(BuildContext context) { final scale = _responsiveScale(context: context, min: 0.82, max: 1.08); final textScale = _effectiveTextScale(context); - return 120 * scale * (1 + (textScale - 1) * 0.12); + return 145 * scale * (1 + (textScale - 1) * 0.12); } double _exploreSectionHeight(BuildContext context) { final cardSize = _exploreCardSize(context); final textScale = _effectiveTextScale(context); - return cardSize + 55 + ((textScale - 1) * 12); + return cardSize + 58 + ((textScale - 1) * 12); } @override @@ -542,7 +556,7 @@ class _HomeTabState extends ConsumerState pending != query && mounted && _urlController.text.trim() == pending) { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); if (mounted && _urlController.text.trim() == pending) { _executeLiveSearch(pending); } @@ -564,6 +578,7 @@ class _HomeTabState extends ConsumerState '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}'; if (_lastSearchQuery == searchKey) return; _lastSearchQuery = searchKey; + _searchSortOption = _SearchSortOption.defaultOrder; final isBuiltInProvider = searchProvider != null && @@ -666,7 +681,7 @@ class _HomeTabState extends ConsumerState final extensionId = trackState.searchExtensionId; Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => AlbumScreen( albumId: trackState.albumId!, albumName: trackState.albumName!, @@ -693,11 +708,13 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => PlaylistScreen( playlistName: trackState.playlistName!, coverUrl: trackState.coverUrl, tracks: trackState.tracks, + recommendedService: + trackState.searchExtensionId ?? trackState.searchSource, ), ), ); @@ -712,7 +729,7 @@ class _HomeTabState extends ConsumerState final extensionId = trackState.searchExtensionId; Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ArtistScreen( artistId: trackState.artistId!, artistName: trackState.artistName!, @@ -781,7 +798,7 @@ class _HomeTabState extends ConsumerState if (progressDialogInitialized || !mounted) return; progressDialogInitialized = true; progressDialogVisible = true; - showDialog( + showDialog( context: this.context, useRootNavigator: false, barrierDismissible: false, @@ -1281,8 +1298,8 @@ class _HomeTabState extends ConsumerState exploreLoading) const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 5), ), ), @@ -1485,7 +1502,7 @@ class _HomeTabState extends ConsumerState delegate: SliverChildBuilderDelegate((context, index) { if (hasGreeting && index == 0) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( greeting, style: Theme.of(context).textTheme.headlineSmall?.copyWith( @@ -1500,7 +1517,7 @@ class _HomeTabState extends ConsumerState return _buildExploreSection(sections[sectionIndex], colorScheme); } - return const SizedBox(height: 16); + return const SizedBox(height: 24); }, childCount: totalCount), ), ]; @@ -1516,7 +1533,7 @@ class _HomeTabState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), child: Text( section.title, style: Theme.of( @@ -1532,7 +1549,11 @@ class _HomeTabState extends ConsumerState itemCount: section.items.length, itemBuilder: (context, index) { final item = section.items[index]; - return _buildExploreItem(item, colorScheme); + return StaggeredListItem( + index: index, + staggerDelay: const Duration(milliseconds: 50), + child: _buildExploreItem(item, colorScheme), + ); }, ), ), @@ -1579,7 +1600,7 @@ class _HomeTabState extends ConsumerState children: [ ClipRRect( borderRadius: BorderRadius.circular( - isArtist ? cardSize / 2 : 8, + isArtist ? cardSize / 2 : 10, ), child: item.coverUrl != null && item.coverUrl!.isNotEmpty ? CachedNetworkImage( @@ -1618,8 +1639,8 @@ class _HomeTabState extends ConsumerState maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: isArtist ? TextAlign.center : TextAlign.start, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), ), @@ -1632,7 +1653,7 @@ class _HomeTabState extends ConsumerState overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, - fontSize: 11, + fontSize: 12, ), ), ], @@ -1670,7 +1691,7 @@ class _HomeTabState extends ConsumerState case 'album': Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: extensionId, albumId: item.id, @@ -1683,7 +1704,7 @@ class _HomeTabState extends ConsumerState case 'playlist': Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( extensionId: extensionId, playlistId: item.id, @@ -1696,7 +1717,7 @@ class _HomeTabState extends ConsumerState case 'artist': Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionArtistScreen( extensionId: extensionId, artistId: item.id, @@ -1717,7 +1738,7 @@ class _HomeTabState extends ConsumerState void _showTrackBottomSheet(ExploreItem item) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surface, @@ -1863,7 +1884,7 @@ class _HomeTabState extends ConsumerState if (item.albumId != null && item.albumId!.isNotEmpty) { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: item.providerId ?? 'spotify-web', albumId: item.albumId!, @@ -2122,10 +2143,12 @@ class _HomeTabState extends ConsumerState if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && - item.providerId != 'spotify') { + item.providerId != 'spotify' && + item.providerId != 'tidal' && + item.providerId != 'qobuz') { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionArtistScreen( extensionId: item.providerId!, artistId: item.id, @@ -2137,7 +2160,7 @@ class _HomeTabState extends ConsumerState } else { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ArtistScreen( artistId: item.id, artistName: item.name, @@ -2151,7 +2174,7 @@ class _HomeTabState extends ConsumerState if (item.providerId == 'download') { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => DownloadedAlbumScreen( albumName: item.name, artistName: item.subtitle ?? '', @@ -2162,10 +2185,12 @@ class _HomeTabState extends ConsumerState } else if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && - item.providerId != 'spotify') { + item.providerId != 'spotify' && + item.providerId != 'tidal' && + item.providerId != 'qobuz') { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: item.providerId!, albumId: item.id, @@ -2177,7 +2202,7 @@ class _HomeTabState extends ConsumerState } else { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => AlbumScreen( albumId: item.id, albumName: item.name, @@ -2210,10 +2235,12 @@ class _HomeTabState extends ConsumerState if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && - item.providerId != 'spotify') { + item.providerId != 'spotify' && + item.providerId != 'tidal' && + item.providerId != 'qobuz') { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( extensionId: item.providerId!, playlistId: item.id, @@ -2225,7 +2252,7 @@ class _HomeTabState extends ConsumerState } else { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => PlaylistScreen( playlistName: item.name, coverUrl: item.imageUrl, @@ -2248,14 +2275,7 @@ class _HomeTabState extends ConsumerState ); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -2393,6 +2413,168 @@ class _HomeTabState extends ConsumerState ); } + // ── 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( + 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 _applySortToList( + List 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.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 _buildSearchResults({ required List tracks, required List? searchArtists, @@ -2406,6 +2588,15 @@ class _HomeTabState extends ConsumerState required bool showLocalLibraryIndicator, required Map thumbnailSizesByExtensionId, }) { + final hasActualData = + tracks.isNotEmpty || + (searchArtists != null && searchArtists.isNotEmpty) || + (searchAlbums != null && searchAlbums.isNotEmpty) || + (searchPlaylists != null && searchPlaylists.isNotEmpty); + + if (!hasActualData && isLoading) { + return [const SliverToBoxAdapter(child: HomeSearchSkeleton())]; + } if (!hasResults) { return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } @@ -2417,6 +2608,59 @@ class _HomeTabState extends ConsumerState final playlistItems = buckets.playlistItems; final artistItems = buckets.artistItems; + final sortedArtists = searchArtists != null && searchArtists.isNotEmpty + ? _applySortToList( + searchArtists, + (a) => a.name, + (a) => a.name, + (a) => 0, + (a) => null, + ) + : searchArtists; + + final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty + ? _applySortToList( + searchAlbums, + (a) => a.name, + (a) => a.artists, + (a) => 0, + (a) => a.releaseDate, + ) + : searchAlbums; + + final sortedPlaylists = + searchPlaylists != null && searchPlaylists.isNotEmpty + ? _applySortToList( + searchPlaylists, + (p) => p.name, + (p) => p.owner, + (p) => 0, + (p) => null, + ) + : searchPlaylists; + + List sortedTracks; + List 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 = [ if (error != null) SliverToBoxAdapter( @@ -2434,24 +2678,28 @@ class _HomeTabState extends ConsumerState ), ]; - if (searchArtists != null && searchArtists.isNotEmpty) { + bool sortButtonShown = false; + + if (sortedArtists != null && sortedArtists.isNotEmpty) { slivers.addAll( _buildVirtualizedResultSection( title: context.l10n.searchArtists, - itemCount: searchArtists.length, + itemCount: sortedArtists.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _SearchArtistItemWidget( - key: ValueKey('search-artist-${searchArtists[index].id}'), - artist: searchArtists[index], + key: ValueKey('search-artist-${sortedArtists[index].id}'), + artist: sortedArtists[index], showDivider: showDivider, onTap: () => _navigateToArtist( - searchArtists[index].id, - searchArtists[index].name, - searchArtists[index].imageUrl, + sortedArtists[index].id, + sortedArtists[index].name, + sortedArtists[index].imageUrl, ), ), ), ); + sortButtonShown = true; } if (artistItems.isNotEmpty) { @@ -2460,6 +2708,7 @@ class _HomeTabState extends ConsumerState title: context.l10n.searchArtists, itemCount: artistItems.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _CollectionItemWidget( key: ValueKey('artist-${artistItems[index].id}'), item: artistItems[index], @@ -2468,22 +2717,25 @@ class _HomeTabState extends ConsumerState ), ), ); + sortButtonShown = true; } - if (searchAlbums != null && searchAlbums.isNotEmpty) { + if (sortedAlbums != null && sortedAlbums.isNotEmpty) { slivers.addAll( _buildVirtualizedResultSection( title: context.l10n.searchAlbums, - itemCount: searchAlbums.length, + itemCount: sortedAlbums.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _SearchAlbumItemWidget( - key: ValueKey('search-album-${searchAlbums[index].id}'), - album: searchAlbums[index], + key: ValueKey('search-album-${sortedAlbums[index].id}'), + album: sortedAlbums[index], showDivider: showDivider, - onTap: () => _navigateToSearchAlbum(searchAlbums[index]), + onTap: () => _navigateToSearchAlbum(sortedAlbums[index]), ), ), ); + sortButtonShown = true; } if (albumItems.isNotEmpty) { @@ -2492,6 +2744,7 @@ class _HomeTabState extends ConsumerState title: context.l10n.searchAlbums, itemCount: albumItems.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _CollectionItemWidget( key: ValueKey('album-${albumItems[index].id}'), item: albumItems[index], @@ -2500,22 +2753,25 @@ class _HomeTabState extends ConsumerState ), ), ); + sortButtonShown = true; } - if (searchPlaylists != null && searchPlaylists.isNotEmpty) { + if (sortedPlaylists != null && sortedPlaylists.isNotEmpty) { slivers.addAll( _buildVirtualizedResultSection( title: context.l10n.searchPlaylists, - itemCount: searchPlaylists.length, + itemCount: sortedPlaylists.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget( - key: ValueKey('search-playlist-${searchPlaylists[index].id}'), - playlist: searchPlaylists[index], + key: ValueKey('search-playlist-${sortedPlaylists[index].id}'), + playlist: sortedPlaylists[index], showDivider: showDivider, - onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]), + onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]), ), ), ); + sortButtonShown = true; } if (playlistItems.isNotEmpty) { @@ -2524,6 +2780,7 @@ class _HomeTabState extends ConsumerState title: context.l10n.searchPlaylists, itemCount: playlistItems.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _CollectionItemWidget( key: ValueKey('playlist-${playlistItems[index].id}'), item: playlistItems[index], @@ -2532,20 +2789,22 @@ class _HomeTabState extends ConsumerState ), ), ); + sortButtonShown = true; } - if (realTracks.isNotEmpty) { + if (sortedTracks.isNotEmpty) { slivers.addAll( _buildVirtualizedResultSection( title: context.l10n.searchSongs, - itemCount: realTracks.length, + itemCount: sortedTracks.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _TrackItemWithStatus( - key: ValueKey(realTracks[index].id), - track: realTracks[index], - index: realTrackIndexes[index], + key: ValueKey(sortedTracks[index].id), + track: sortedTracks[index], + index: sortedTrackIndexes[index], showDivider: showDivider, - onDownload: () => _downloadTrack(realTrackIndexes[index]), + onDownload: () => _downloadTrack(sortedTrackIndexes[index]), searchExtensionId: searchExtensionId, showLocalLibraryIndicator: showLocalLibraryIndicator, thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, @@ -2563,6 +2822,7 @@ class _HomeTabState extends ConsumerState required int itemCount, required ColorScheme colorScheme, required Widget Function(int index, bool showDivider) itemBuilder, + bool showSortButton = false, }) { final sectionColor = Theme.of(context).brightness == Brightness.dark ? Color.alphaBlend( @@ -2574,12 +2834,47 @@ class _HomeTabState extends ConsumerState return [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + if (showSortButton) + SizedBox( + height: 32, + child: TextButton.icon( + onPressed: () => _showSortOptions(colorScheme), + icon: Icon( + Icons.swap_vert, + size: 18, + color: _searchSortOption != _SearchSortOption.defaultOrder + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + label: Text( + _searchSortOption != _SearchSortOption.defaultOrder + ? _sortOptionLabel(_searchSortOption) + : context.l10n.libraryFilterSort, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: + _searchSortOption != _SearchSortOption.defaultOrder + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + ), + ), + ), + ], ), ), ), @@ -2587,19 +2882,22 @@ class _HomeTabState extends ConsumerState delegate: SliverChildBuilderDelegate((context, index) { final isFirst = index == 0; final isLast = index == itemCount - 1; - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: sectionColor, - borderRadius: BorderRadius.vertical( - top: isFirst ? const Radius.circular(20) : Radius.zero, - bottom: isLast ? const Radius.circular(20) : Radius.zero, + return StaggeredListItem( + index: index, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: sectionColor, + borderRadius: BorderRadius.vertical( + top: isFirst ? const Radius.circular(20) : Radius.zero, + bottom: isLast ? const Radius.circular(20) : Radius.zero, + ), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: itemBuilder(index, !isLast), ), - ), - clipBehavior: Clip.antiAlias, - child: Material( - color: Colors.transparent, - child: itemBuilder(index, !isLast), ), ); }, childCount: itemCount), @@ -2612,7 +2910,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ArtistScreen( artistId: artistId, artistName: artistName, @@ -2638,7 +2936,7 @@ class _HomeTabState extends ConsumerState // Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => AlbumScreen( albumId: album.id, albumName: album.name, @@ -2665,7 +2963,7 @@ class _HomeTabState extends ConsumerState // Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => PlaylistScreen( playlistName: playlist.name, coverUrl: playlist.imageUrl, @@ -2701,7 +2999,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: extensionId, albumId: albumItem.id, @@ -2737,7 +3035,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( extensionId: extensionId, playlistId: playlistItem.id, @@ -2772,7 +3070,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionArtistScreen( extensionId: extensionId, artistId: artistItem.id, @@ -2793,7 +3091,6 @@ class _HomeTabState extends ConsumerState } if (searchProvider != null && searchProvider.isNotEmpty) { - // Check built-in providers first if (searchProvider == 'tidal') { return 'Search with Tidal...'; } @@ -2835,16 +3132,6 @@ class _HomeTabState extends ConsumerState _triggerSearchWithFilter(null); }, showCheckmark: false, - selectedColor: colorScheme.primaryContainer, - backgroundColor: colorScheme.surfaceContainerHighest, - labelStyle: TextStyle( - color: selectedFilter == null - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: selectedFilter == null - ? FontWeight.w600 - : FontWeight.normal, - ), ), ), ...filters.map((filter) { @@ -2859,24 +3146,8 @@ class _HomeTabState extends ConsumerState _triggerSearchWithFilter(filter.id); }, showCheckmark: false, - selectedColor: colorScheme.primaryContainer, - backgroundColor: colorScheme.surfaceContainerHighest, - labelStyle: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - ), avatar: filter.icon != null - ? Icon( - _getFilterIcon(filter.icon!), - size: 18, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ) + ? Icon(_getFilterIcon(filter.icon!), size: 18) : null, ), ); @@ -2913,7 +3184,6 @@ class _HomeTabState extends ConsumerState if (text.isEmpty || text.length < _minLiveSearchChars) return; if (text.startsWith('http') || text.startsWith('spotify:')) return; - // Reset last search query to force new search _lastSearchQuery = null; _performSearch(text, filterOverride: filter); } @@ -2931,15 +3201,11 @@ class _HomeTabState extends ConsumerState fillColor: colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.5), - ), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.5), - ), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), @@ -2987,6 +3253,9 @@ class _HomeTabState extends ConsumerState ), ), onSubmitted: (_) => _onSearchSubmitted(), + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, ); } @@ -3035,7 +3304,6 @@ class _SearchProviderDropdown extends ConsumerWidget { .firstOrNull; } - // Check if current provider is a built-in provider (tidal/qobuz) const builtInProviders = {'tidal', 'qobuz'}; final isBuiltInProvider = currentProvider != null && builtInProviders.contains(currentProvider); @@ -3115,7 +3383,6 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), - // Built-in Tidal search option PopupMenuItem( value: 'tidal', child: Row( @@ -3143,7 +3410,6 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), - // Built-in Qobuz search option PopupMenuItem( value: 'qobuz', child: Row( @@ -3966,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState { .map((t) => _parseTrack(t as Map)) .toList(); - // Extract artist info from album response final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistName = result['artists'] as String?; @@ -4024,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState { if (_isLoading) { return Scaffold( 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) { return Scaffold( 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, coverUrl: widget.coverUrl, tracks: _tracks!, + recommendedService: widget.extensionId, ); } } @@ -4349,7 +4618,7 @@ class _ExtensionArtistScreenState extends ConsumerState { if (_isLoading) { return Scaffold( appBar: AppBar(title: Text(widget.artistName)), - body: const Center(child: CircularProgressIndicator()), + body: const ArtistScreenSkeleton(), ); } diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index 503937f6..388a8327 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; class LibraryPlaylistsScreen extends ConsumerWidget { @@ -118,7 +119,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ), onTap: () { Navigator.of(context).push( - MaterialPageRoute( + MaterialPageRoute( builder: (_) => LibraryTracksFolderScreen( mode: LibraryTracksFolderMode.playlist, playlistId: playlist.id, @@ -148,7 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - _PlaylistOptionTile( + BottomSheetOptionTile( icon: Icons.edit_outlined, title: context.l10n.collectionRenamePlaylist, onTap: () { @@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { }, ), - _PlaylistOptionTile( + BottomSheetOptionTile( icon: Icons.image_outlined, title: context.l10n.collectionPlaylistChangeCover, onTap: () { @@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { }, ), - _PlaylistOptionTile( + BottomSheetOptionTile( icon: Icons.delete_outline, iconColor: colorScheme.error, title: context.l10n.collectionDeletePlaylist, @@ -543,40 +544,3 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ); } } - -/// Styled like _OptionTile in track_collection_quick_actions.dart -class _PlaylistOptionTile extends StatelessWidget { - final IconData icon; - final Color? iconColor; - final String title; - final VoidCallback onTap; - - const _PlaylistOptionTile({ - required this.icon, - this.iconColor, - required this.title, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - icon, - color: iconColor ?? colorScheme.onPrimaryContainer, - size: 20, - ), - ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - onTap: onTap, - ); - } -} diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 2b792b0b..a4acc687 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -15,7 +15,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class LibraryTracksFolderScreen extends ConsumerStatefulWidget { final LibraryTracksFolderMode mode; @@ -272,7 +274,6 @@ class _LibraryTracksFolderScreenState break; } - // Stale selection cleanup if (_isSelectionMode) { final validKeys = entries.map((e) => e.key).toSet(); _selectedKeys.removeWhere((key) => !validKeys.contains(key)); @@ -348,20 +349,23 @@ class _LibraryTracksFolderScreenState final isSelected = _selectedKeys.contains(entry.key); return KeyedSubtree( key: ValueKey(entry.key), - child: _CollectionTrackTile( - entry: entry, - mode: widget.mode, - playlistId: widget.playlistId, - localLibraryState: localState, - folderTracks: folderTracks, - isSelectionMode: _isSelectionMode, - isSelected: isSelected, - onTap: _isSelectionMode - ? () => _toggleSelection(entry.key) - : null, - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(entry.key), + child: StaggeredListItem( + index: index, + child: _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + localLibraryState: localState, + folderTracks: folderTracks, + isSelectionMode: _isSelectionMode, + isSelected: isSelected, + onTap: _isSelectionMode + ? () => _toggleSelection(entry.key) + : null, + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(entry.key), + ), ), ); }, childCount: entries.length), @@ -372,7 +376,6 @@ class _LibraryTracksFolderScreenState ], ), - // Selection bottom bar AnimatedPositioned( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, @@ -844,7 +847,7 @@ class _LibraryTracksFolderScreenState void _confirmDownloadAll(List tracks) { if (tracks.isEmpty) return; - showDialog( + showDialog( context: context, builder: (dialogContext) { final colorScheme = Theme.of(dialogContext).colorScheme; @@ -977,7 +980,7 @@ class _LibraryTracksFolderScreenState void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1081,14 +1084,19 @@ class _CollectionTrackTile extends ConsumerWidget { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; final effectiveCoverUrl = _resolveCoverUrl(track); - final isInHistory = ref.watch( + + // Fine-grained provider watches – only this tile rebuilds when its own + // history / local-library entry changes. + final historyItem = ref.watch( downloadHistoryProvider.select((state) { - if (state.isDownloaded(track.id)) return true; + final byId = state.getBySpotifyId(track.id); + if (byId != null) return byId; final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { - return true; + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = state.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; } - return state.findByTrackAndArtist(track.name, track.artistName) != null; + return state.findByTrackAndArtist(track.name, track.artistName); }), ); final showLocalLibraryIndicator = ref.watch( @@ -1096,17 +1104,26 @@ class _CollectionTrackTile extends ConsumerWidget { (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, ), ); - final isInLocalLibrary = showLocalLibraryIndicator + final localItem = showLocalLibraryIndicator ? ref.watch( - localLibraryProvider.select( - (state) => state.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ), - ), + localLibraryProvider.select((state) { + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = state.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; + } + return state.findByTrackAndArtist(track.name, track.artistName); + }), ) - : false; + : null; + + final isInHistory = historyItem != null; + final isInLocalLibrary = localItem != null; + final heroTag = historyItem != null + ? 'cover_${historyItem.id}' + : localItem != null + ? 'cover_lib_${localItem.id}' + : null; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1124,43 +1141,51 @@ class _CollectionTrackTile extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty - ? _buildTrackCover(context, effectiveCoverUrl, 52) - : Container( - width: 52, - height: 52, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, + HeroMode( + enabled: heroTag != null, + child: heroTag != null + ? Hero( + tag: heroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ), ), ], @@ -1313,7 +1338,7 @@ class _CollectionTrackTile extends ConsumerWidget { final showAddToPlaylist = mode != LibraryTracksFolderMode.wishlist || isDownloaded; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1390,9 +1415,8 @@ class _CollectionTrackTile extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - // Add to playlist (hidden in wishlist unless already downloaded) if (showAddToPlaylist) - _CollectionOptionTile( + BottomSheetOptionTile( icon: Icons.playlist_add, title: context.l10n.collectionAddToPlaylist, onTap: () { @@ -1401,8 +1425,7 @@ class _CollectionTrackTile extends ConsumerWidget { }, ), - // Remove from folder / playlist - _CollectionOptionTile( + BottomSheetOptionTile( icon: Icons.remove_circle_outline, iconColor: colorScheme.error, title: mode == LibraryTracksFolderMode.playlist @@ -1501,14 +1524,7 @@ class _CollectionTrackTile extends ConsumerWidget { if (historyItem != null) { await Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: historyItem)), ); return; } @@ -1525,14 +1541,7 @@ class _CollectionTrackTile extends ConsumerWidget { if (localItem != null) { await Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(localItem: localItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(localItem: localItem)), ); 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 { final IconData icon; final String label; diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 5c3c8ea3..09ff33df 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -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/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class LocalAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -531,7 +532,6 @@ class _LocalAlbumScreenState extends ConsumerState { if (tracks.isEmpty) return null; final first = tracks.first; - // For lossy formats, use bitrate if (first.bitrate != null && first.bitrate! > 0) { final fmt = first.format?.toUpperCase() ?? ''; final firstBitrate = first.bitrate; @@ -543,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState { return '$fmt ${firstBitrate}kbps'.trim(); } - // For lossless formats, use bit depth / sample rate if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) { @@ -630,7 +629,10 @@ class _LocalAlbumScreenState extends ConsumerState { final track = discTracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: index, + child: _buildTrackItem(context, colorScheme, track), + ), ); }, childCount: discTracks.length), ), @@ -669,28 +671,11 @@ class _LocalAlbumScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], @@ -1195,7 +1180,7 @@ class _LocalAlbumScreenState extends ConsumerState { ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( @@ -1382,7 +1367,6 @@ class _LocalAlbumScreenState extends ConsumerState { } } if (currentFormat == null || currentFormat == targetFormat) continue; - // Skip lossy sources when target is lossless (pointless re-encoding) final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; @@ -1503,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState { bitrate: bitrate, metadata: metadata, coverPath: coverPath, - deleteOriginal: !isSaf, // Only delete original for regular files + deleteOriginal: !isSaf, ); if (coverPath != null) { @@ -1522,15 +1506,9 @@ class _LocalAlbumScreenState extends ConsumerState { } if (isSaf) { - // For SAF: derive the parent tree URI and relative dir from the content URI, - // then create new SAF file and delete old one - // Parse the SAF URI to get the tree document path: - // content://...tree/...document/.../oldName.flac - // We need tree URI and relative dir to create the new file final uri = Uri.parse(item.filePath); final pathSegments = uri.pathSegments; - // Try to find 'tree' and 'document' segments String? treeUri; String relativeDir = ''; String oldFileName = ''; diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 0440a08c..c6a8b768 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MainShell'); @@ -31,9 +32,11 @@ class MainShell extends ConsumerStatefulWidget { ConsumerState createState() => _MainShellState(); } -class _MainShellState extends ConsumerState { +class _MainShellState extends ConsumerState + with SingleTickerProviderStateMixin { int _currentIndex = 0; late final PageController _pageController; + late final AnimationController _tabJumpTransitionController; bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; @@ -48,6 +51,11 @@ class _MainShellState extends ConsumerState { void initState() { super.initState(); _pageController = PageController(initialPage: _currentIndex); + _tabJumpTransitionController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + value: 1, + ); ShellNavigationService.syncState( currentTabIndex: _currentIndex, showStoreTab: false, @@ -71,7 +79,7 @@ class _MainShellState extends ConsumerState { _log.d('Received shared URL from stream: $url'); _handleSharedUrl(url); }, - onError: (error) { + onError: (Object error) { _log.e('Share stream error: $error'); }, cancelOnError: false, @@ -84,7 +92,7 @@ class _MainShellState extends ConsumerState { if (!extState.isInitialized) { _log.d('Waiting for extensions to initialize before handling URL...'); for (int i = 0; i < 50; i++) { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); if (!mounted) return; if (ref.read(extensionProvider).isInitialized) { _log.d('Extensions initialized, proceeding with URL handling'); @@ -154,7 +162,6 @@ class _MainShellState extends ConsumerState { if (!Platform.isAndroid) return; final settings = ref.read(settingsProvider); - // Only show if user is still on legacy storage mode with a download dir set if (settings.storageMode == 'saf') return; if (settings.downloadDirectory.isEmpty) return; @@ -170,7 +177,7 @@ class _MainShellState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; - showDialog( + showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( @@ -229,6 +236,7 @@ class _MainShellState extends ConsumerState { void dispose() { _shareSubscription?.cancel(); _pageController.dispose(); + _tabJumpTransitionController.dispose(); super.dispose(); } @@ -251,7 +259,8 @@ class _MainShellState extends ConsumerState { } if (_currentIndex != index) { - final shouldResetHome = index == 0; + final previousIndex = _currentIndex; + final isNonAdjacentJump = (previousIndex - index).abs() > 1; HapticFeedback.selectionClick(); setState(() => _currentIndex = index); final showStore = ref.read( @@ -262,19 +271,23 @@ class _MainShellState extends ConsumerState { showStoreTab: showStore, ); FocusManager.instance.primaryFocus?.unfocus(); - if (shouldResetHome) { - _resetHomeToMain(); + // Jump directly when skipping intermediate tabs to avoid + // sliding through them. For those jumps, keep a short fade-in + // so the transition still feels intentional. + if (isNonAdjacentJump) { + _pageController.jumpToPage(index); + _tabJumpTransitionController.forward(from: 0); + } else { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); } - _pageController.animateToPage( - index, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); } } void _onPageChanged(int index) { - final previousIndex = _currentIndex; if (_currentIndex != index) { setState(() => _currentIndex = index); final showStore = ref.read( @@ -285,9 +298,6 @@ class _MainShellState extends ConsumerState { showStoreTab: showStore, ); FocusManager.instance.primaryFocus?.unfocus(); - if (index == 0 && previousIndex != 0) { - _resetHomeToMain(); - } } } @@ -451,32 +461,44 @@ class _MainShellState extends ConsumerState { label: l10n.navHome, ), NavigationDestination( - icon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.library_music_outlined), - ), - selectedIcon: SlidingIcon( + icon: AnimatedBadge( + count: queueState, child: Badge( isLabelVisible: queueState > 0, label: Text('$queueState'), - child: const Icon(Icons.library_music), + child: const Icon(Icons.library_music_outlined), + ), + ), + selectedIcon: SlidingIcon( + child: AnimatedBadge( + count: queueState, + child: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.library_music), + ), ), ), label: l10n.navLibrary, ), if (showStore) NavigationDestination( - icon: Badge( - isLabelVisible: storeUpdatesCount > 0, - label: Text('$storeUpdatesCount'), - child: const Icon(Icons.store_outlined), - ), - selectedIcon: SwingIcon( + icon: AnimatedBadge( + count: storeUpdatesCount, child: Badge( isLabelVisible: storeUpdatesCount > 0, label: Text('$storeUpdatesCount'), - child: const Icon(Icons.store), + child: const Icon(Icons.store_outlined), + ), + ), + selectedIcon: SwingIcon( + child: AnimatedBadge( + count: storeUpdatesCount, + child: Badge( + isLabelVisible: storeUpdatesCount > 0, + label: Text('$storeUpdatesCount'), + child: const Icon(Icons.store), + ), ), ), label: l10n.navStore, @@ -504,15 +526,27 @@ class _MainShellState extends ConsumerState { return true; }, child: Scaffold( - body: PageView.builder( - controller: _pageController, - itemCount: tabs.length, - onPageChanged: _onPageChanged, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => _KeepAliveTabPage( - key: ValueKey('page-$index'), - child: tabs[index], + body: AnimatedBuilder( + animation: _tabJumpTransitionController, + child: PageView.builder( + controller: _pageController, + itemCount: tabs.length, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => _KeepAliveTabPage( + key: ValueKey('page-$index'), + child: tabs[index], + ), ), + builder: (context, child) { + final t = Curves.easeOutCubic.transform( + _tabJumpTransitionController.value, + ); + return Opacity( + opacity: t, + child: Transform.scale(scale: 0.985 + (0.015 * t), child: child), + ); + }, ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), @@ -707,7 +741,7 @@ class _SwingIconState extends State TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20), TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20), TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20), - ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + ]).animate(_controller); _controller.forward(); } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index fb4818ea..364324a0 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -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/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; final String? coverUrl; final List tracks; final String? playlistId; + final String? recommendedService; const PlaylistScreen({ super.key, @@ -28,6 +30,7 @@ class PlaylistScreen extends ConsumerStatefulWidget { this.coverUrl, required this.tracks, this.playlistId, + this.recommendedService, }); @override @@ -47,6 +50,31 @@ class _PlaylistScreenState extends ConsumerState { String get _playlistName => _resolvedPlaylistName ?? widget.playlistName; String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl; + String? _recommendedDownloadService() { + final explicit = widget.recommendedService; + if (explicit != null && explicit.isNotEmpty) { + return explicit; + } + + final playlistId = widget.playlistId; + if (playlistId != null) { + if (playlistId.startsWith('tidal:')) return 'tidal'; + if (playlistId.startsWith('qobuz:')) return 'qobuz'; + if (playlistId.startsWith('deezer:')) return 'deezer'; + } + + final source = _tracks.firstOrNull?.source; + if (source != null && source.isNotEmpty) { + return source; + } + + final trackId = _tracks.firstOrNull?.id ?? ''; + if (trackId.startsWith('tidal:')) return 'tidal'; + if (trackId.startsWith('qobuz:')) return 'qobuz'; + if (trackId.startsWith('deezer:')) return 'deezer'; + return null; + } + @override void initState() { super.initState(); @@ -360,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState { if (_isLoading) { return const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 8), ), ); } @@ -411,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState { final track = _tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _PlaylistTrackItem( - track: track, - onDownload: () => _downloadTrack(context, track), + child: StaggeredListItem( + index: index, + child: _PlaylistTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), ), ); }, childCount: _tracks.length), @@ -429,6 +460,7 @@ class _PlaylistScreenState extends ConsumerState { trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) @@ -546,7 +578,7 @@ class _PlaylistScreenState extends ConsumerState { void _confirmDownloadAll(BuildContext context) { if (_tracks.isEmpty) return; - showDialog( + showDialog( context: context, builder: (dialogContext) { final colorScheme = Theme.of(dialogContext).colorScheme; @@ -616,7 +648,6 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTracks(BuildContext context, List tracks) { if (tracks.isEmpty) return; - // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = @@ -663,6 +694,7 @@ class _PlaylistScreenState extends ConsumerState { context, trackName: '${tracksToQueue.length} tracks', artistName: _playlistName, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) @@ -725,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget { }), ); - // Check local library for duplicate detection final showLocalLibraryIndicator = ref.watch( settingsProvider.select( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0e632411..7a4465ed 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -34,6 +34,7 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; enum LibraryItemSource { downloaded, local } @@ -96,7 +97,6 @@ class UnifiedLibraryItem { } else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) { - // Lossless format with actual bit depth quality = buildDisplayAudioQuality( bitDepth: item.bitDepth, sampleRate: item.sampleRate, @@ -107,7 +107,7 @@ class UnifiedLibraryItem { trackName: item.trackName, artistName: item.artistName, albumName: item.albumName, - coverUrl: null, // Local library doesn't have cover URLs + coverUrl: null, localCoverPath: item.coverPath, filePath: item.filePath, quality: quality, @@ -169,9 +169,6 @@ class UnifiedLibraryItem { } if (localItem != null) { final l = localItem!; - // Store coverPath (even local file paths) in coverUrl so playlist - // entries retain the cover. All renderers must check whether the - // value is a URL or a local path and use the appropriate widget. return Track( id: l.id, name: l.trackName, @@ -187,7 +184,6 @@ class UnifiedLibraryItem { source: 'local', ); } - // Fallback — should not happen return Track( id: id, name: trackName, @@ -660,8 +656,8 @@ final _queueFilteredAlbumsProvider = }); Map> _filterHistoryInIsolate(Map payload) { - final entries = (payload['entries'] as List).cast(); - final albumCounts = (payload['albumCounts'] as Map).cast(); + final entries = (payload['entries'] as List).cast>(); + final albumCounts = Map.from(payload['albumCounts'] as Map); final query = (payload['query'] as String?) ?? ''; final hasQuery = query.isNotEmpty; @@ -715,6 +711,9 @@ class _QueueTabState extends ConsumerState { static const int _maxCacheSize = 500; static const int _maxSearchIndexCacheSize = 4000; bool _embeddedCoverRefreshScheduled = false; + // Version counter to trigger targeted cover image rebuilds + // without rebuilding the entire widget tree via setState. + final ValueNotifier _embeddedCoverVersion = ValueNotifier(0); bool _isSelectionMode = false; final Set _selectedIds = {}; @@ -766,6 +765,12 @@ class _QueueTabState extends ConsumerState { String _localFilterQueryCache = ''; List _filteredLocalItemsCache = const []; final Map _unifiedItemsCache = {}; + List? _cachedUnifiedDownloadedSource; + List _cachedUnifiedDownloaded = const []; + List? _cachedUnifiedLocalSource; + List _cachedUnifiedLocal = const []; + List? _cachedDownloadedPathKeysSource; + Set _cachedDownloadedPathKeys = const {}; final Map _filterContentDataCache = {}; List? _filterCacheAllHistoryItems; _HistoryStats? _filterCacheHistoryStats; @@ -776,7 +781,6 @@ class _QueueTabState extends ConsumerState { String? _filterCacheQuality; String? _filterCacheFormat; String _filterCacheSortMode = 'latest'; - // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg' @@ -818,6 +822,7 @@ class _QueueTabState extends ConsumerState { } _fileExistsNotifiers.clear(); _alwaysMissingFileNotifier.dispose(); + _embeddedCoverVersion.dispose(); _filterPageController?.dispose(); _searchController.dispose(); _searchFocusNode.dispose(); @@ -898,12 +903,18 @@ class _QueueTabState extends ConsumerState { _historyStatsCache = historyStats; if (historyChanged) { _searchIndexCache.clear(); + _cachedUnifiedDownloadedSource = null; + _cachedUnifiedDownloaded = const []; + _cachedDownloadedPathKeysSource = null; + _cachedDownloadedPathKeys = const {}; } if (localChanged) { _localSearchIndexCache.clear(); _localFilterItemsCache = null; _localFilterQueryCache = ''; _filteredLocalItemsCache = const []; + _cachedUnifiedLocalSource = null; + _cachedUnifiedLocal = const []; } _unifiedItemsCache.clear(); _invalidateFilterContentCache(); @@ -952,6 +963,45 @@ class _QueueTabState extends ConsumerState { return searchKey; } + List _unifiedDownloadedItems( + List items, + ) { + if (identical(items, _cachedUnifiedDownloadedSource)) { + return _cachedUnifiedDownloaded; + } + final unified = items + .map(UnifiedLibraryItem.fromDownloadHistory) + .toList(growable: false); + _cachedUnifiedDownloadedSource = items; + _cachedUnifiedDownloaded = unified; + return unified; + } + + List _unifiedLocalItems(List items) { + if (identical(items, _cachedUnifiedLocalSource)) { + return _cachedUnifiedLocal; + } + final unified = items + .map(UnifiedLibraryItem.fromLocalLibrary) + .toList(growable: false); + _cachedUnifiedLocalSource = items; + _cachedUnifiedLocal = unified; + return unified; + } + + Set _downloadedPathKeys(List historyItems) { + if (identical(historyItems, _cachedDownloadedPathKeysSource)) { + return _cachedDownloadedPathKeys; + } + final keys = {}; + for (final item in historyItems) { + keys.addAll(buildPathMatchKeys(item.filePath)); + } + _cachedDownloadedPathKeysSource = historyItems; + _cachedDownloadedPathKeys = Set.unmodifiable(keys); + return _cachedDownloadedPathKeys; + } + List _filterLocalItems( List items, String query, @@ -1506,14 +1556,14 @@ class _QueueTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$selectedCount selected', + context.l10n.selectionSelected(selectedCount), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), Text( allSelected - ? 'All playlists selected' - : 'Tap playlists to select', + ? context.l10n.selectionAllPlaylistsSelected + : context.l10n.selectionTapPlaylistsToSelect, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), @@ -1533,7 +1583,11 @@ class _QueueTabState extends ConsumerState { allSelected ? Icons.deselect : Icons.select_all, size: 20, ), - label: Text(allSelected ? 'Deselect' : 'Select All'), + label: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + ), style: TextButton.styleFrom( foregroundColor: colorScheme.primary, ), @@ -1584,7 +1638,7 @@ class _QueueTabState extends ConsumerState { label: Text( selectedCount > 0 ? 'Delete $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}' - : 'Select playlists to delete', + : context.l10n.selectionSelectPlaylistsToDelete, ), style: FilledButton.styleFrom( backgroundColor: selectedCount > 0 @@ -1712,7 +1766,9 @@ class _QueueTabState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { _embeddedCoverRefreshScheduled = false; if (mounted) { - setState(() {}); + // Increment version to trigger ValueListenableBuilder rebuilds + // on cover images only, instead of rebuilding the entire widget tree. + _embeddedCoverVersion.value++; } }); } @@ -1864,7 +1920,6 @@ class _QueueTabState extends ConsumerState { .toList(growable: false); } - // Apply sorting return _applySorting(filtered); } @@ -1913,7 +1968,7 @@ class _QueueTabState extends ConsumerState { String? tempFormat = _filterFormat; String tempSortMode = _sortMode; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, @@ -2225,14 +2280,7 @@ class _QueueTabState extends ConsumerState { final beforeModTime = await _readFileModTimeMillis(historyItem.filePath); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: historyItem)), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2258,14 +2306,7 @@ class _QueueTabState extends ConsumerState { final beforeModTime = await _readFileModTimeMillis(item.filePath); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2286,14 +2327,7 @@ class _QueueTabState extends ConsumerState { _searchFocusNode.unfocus(); Navigator.push( context, - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(localItem: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(localItem: item)), ).then((_) => _searchFocusNode.unfocus()); } @@ -2332,83 +2366,66 @@ class _QueueTabState extends ConsumerState { } } - void _navigateToDownloadedAlbum(_GroupedAlbum album) { + /// Navigate with unfocus pattern — unfocuses search before and after navigation. + void _navigateWithUnfocus(Route route) { _searchFocusNode.unfocus(); - Navigator.push( - context, - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - DownloadedAlbumScreen( - albumName: album.albumName, - artistName: album.artistName, - coverUrl: album.coverUrl, - ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), + Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus()); + } + + void _navigateToDownloadedAlbum(_GroupedAlbum album) { + _navigateWithUnfocus( + slidePageRoute( + page: DownloadedAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverUrl: album.coverUrl, + ), ), - ).then((_) => _searchFocusNode.unfocus()); + ); } void _navigateToLocalAlbum(_GroupedLocalAlbum album) { - _searchFocusNode.unfocus(); - Navigator.push( - context, - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - LocalAlbumScreen( - albumName: album.albumName, - artistName: album.artistName, - coverPath: album.coverPath, - tracks: album.tracks, - ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), + _navigateWithUnfocus( + slidePageRoute( + page: LocalAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverPath: album.coverPath, + tracks: album.tracks, + ), ), - ).then((_) => _searchFocusNode.unfocus()); + ); } void _openWishlistFolder() { - _searchFocusNode.unfocus(); - Navigator.of(context) - .push( - MaterialPageRoute( - builder: (_) => const LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.wishlist, - ), - ), - ) - .then((_) => _searchFocusNode.unfocus()); + _navigateWithUnfocus( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.wishlist, + ), + ), + ); } void _openLovedFolder() { - _searchFocusNode.unfocus(); - Navigator.of(context) - .push( - MaterialPageRoute( - builder: (_) => const LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.loved, - ), - ), - ) - .then((_) => _searchFocusNode.unfocus()); + _navigateWithUnfocus( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.loved, + ), + ), + ); } void _openPlaylistById(String playlistId) { - _searchFocusNode.unfocus(); - Navigator.of(context) - .push( - MaterialPageRoute( - builder: (_) => LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.playlist, - playlistId: playlistId, - ), - ), - ) - .then((_) => _searchFocusNode.unfocus()); + _navigateWithUnfocus( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlistId, + ), + ), + ); } Future _showCreatePlaylistDialog(BuildContext context) async { @@ -2610,7 +2627,6 @@ class _QueueTabState extends ConsumerState { return; } - // Single track drop final track = item.toTrack(); final added = await notifier.addTrackToPlaylist(playlistId, track); @@ -2677,14 +2693,27 @@ class _QueueTabState extends ConsumerState { final allHistoryItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); - // Watch local library items final localLibraryEnabled = ref.watch( settingsProvider.select((s) => s.localLibraryEnabled), ); final localLibraryItems = localLibraryEnabled ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; - final collectionState = ref.watch(libraryCollectionsProvider); + // Watch with selector on key fields to reduce unnecessary rebuilds. + // LibraryCollectionsState doesn't implement == so watching without + // selector rebuilds on every provider notification. + ref.watch( + libraryCollectionsProvider.select( + (s) => ( + s.wishlistCount, + s.lovedCount, + s.playlistCount, + s.hasPlaylistTracks, + s.isLoaded, + ), + ), + ); + final collectionState = ref.read(libraryCollectionsProvider); final historyStats = ref.watch(_queueHistoryStatsProvider); final filteredGrouped = ref.watch( _queueFilteredAlbumsProvider( @@ -2733,20 +2762,27 @@ class _QueueTabState extends ConsumerState { ); } - final bottomPadding = MediaQuery.of(context).padding.bottom; + final bottomPadding = MediaQuery.paddingOf(context).bottom; final selectionItems = getFilterData( historyFilterMode, ).filteredUnifiedItems; - WidgetsBinding.instance.addPostFrameCallback((_) { - _syncSelectionOverlay( - items: selectionItems, - bottomPadding: bottomPadding, - ); - _syncPlaylistSelectionOverlay( - playlists: collectionState.playlists, - bottomPadding: bottomPadding, - ); - }); + // Only sync overlays when selection mode is active + if (_isSelectionMode || _isPlaylistSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isSelectionMode) { + _syncSelectionOverlay( + items: selectionItems, + bottomPadding: bottomPadding, + ); + } + if (_isPlaylistSelectionMode) { + _syncPlaylistSelectionOverlay( + playlists: collectionState.playlists, + bottomPadding: bottomPadding, + ); + } + }); + } return PopScope( canPop: !_isSelectionMode && !_isPlaylistSelectionMode, @@ -2823,6 +2859,7 @@ class _QueueTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( + tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -2837,26 +2874,24 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.outlineVariant, - width: 1, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.outlineVariant, - width: 1.5, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.primary, - width: 2.5, + width: 2, ), ), contentPadding: const EdgeInsets.symmetric( horizontal: 20, - vertical: 12, + vertical: 16, ), ), onChanged: _onSearchChanged, @@ -2879,7 +2914,6 @@ class _QueueTabState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Builder( builder: (context) { - // Compute filtered counts for tab chips int filteredAllCount; int filteredAlbumCount; int filteredSingleCount; @@ -2982,9 +3016,7 @@ class _QueueTabState extends ConsumerState { return cached.items; } - final unifiedDownloaded = historyItems - .map((item) => UnifiedLibraryItem.fromDownloadHistory(item)) - .toList(growable: false); + final unifiedDownloaded = _unifiedDownloadedItems(historyItems); List localItemsForMerge; if (filterMode == 'all') { @@ -2999,14 +3031,8 @@ class _QueueTabState extends ConsumerState { localItemsForMerge = _filterLocalItems(localSingles, query); } - final unifiedLocal = localItemsForMerge - .map((item) => UnifiedLibraryItem.fromLocalLibrary(item)) - .toList(growable: false); - - final downloadedPathKeys = {}; - for (final item in unifiedDownloaded) { - downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); - } + final unifiedLocal = _unifiedLocalItems(localItemsForMerge); + final downloadedPathKeys = _downloadedPathKeys(historyItems); final dedupedUnifiedLocal = []; for (final item in unifiedLocal) { @@ -3102,7 +3128,7 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ Text( - 'Downloading ($queueCount)', + context.l10n.queueDownloadingCount(queueCount), style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -3288,238 +3314,91 @@ class _QueueTabState extends ConsumerState { ); } - /// Build a collection item at [index] for the unified "All" tab grid view. - /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + /// Returns the visible collection entries, hiding Wishlist/Loved when empty. + List<_CollectionEntry> _getVisibleCollectionEntries( + LibraryCollectionsState collectionState, + ) { + final entries = <_CollectionEntry>[]; + if (collectionState.wishlistCount > 0) { + entries.add(_CollectionEntry.wishlist); + } + if (collectionState.lovedCount > 0) { + entries.add(_CollectionEntry.loved); + } + for (var i = 0; i < collectionState.playlists.length; i++) { + entries.add(_CollectionEntry.playlist(i)); + } + return entries; + } + + /// Build a collection item for the unified "All" tab grid view. Widget _buildAllTabGridCollectionItem({ required BuildContext context, required ColorScheme colorScheme, - required int index, + required _CollectionEntry entry, required LibraryCollectionsState collectionState, List filteredUnifiedItems = const [], }) { - if (index == 0) { - return _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - icon: Icons.add_circle_outline, - iconColor: Colors.white, - iconBgColor: const Color(0xFF1DB954), - title: context.l10n.collectionWishlist, - count: collectionState.wishlistCount, - onTap: _openWishlistFolder, - ); - } else if (index == 1) { - return _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - icon: Icons.favorite, - iconColor: Colors.white, - iconBgColor: const Color(0xFF8C67AC), - title: context.l10n.collectionLoved, - count: collectionState.lovedCount, - onTap: _openLovedFolder, - ); - } else { - final playlist = collectionState.playlists[index - 2]; - final isSelected = _selectedPlaylistIds.contains(playlist.id); - return DragTarget( - onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, - onAcceptWithDetails: (details) { - _onTrackDroppedOnPlaylist( - context, - details.data, - playlist.id, - playlist.name, - allItems: filteredUnifiedItems, - ); - }, - builder: (context, candidateData, rejectedData) { - final isHovering = candidateData.isNotEmpty; - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: isHovering - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.primary, width: 2), - color: colorScheme.primary.withValues(alpha: 0.1), - ) - : null, - child: Stack( - children: [ - _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - coverWidget: _buildPlaylistCover( - context, - playlist, - colorScheme, - ), - title: playlist.name, - count: playlist.tracks.length, - onTap: _isPlaylistSelectionMode - ? () => _togglePlaylistSelection(playlist.id) - : () => _openPlaylistById(playlist.id), - onLongPress: _isPlaylistSelectionMode - ? () => _togglePlaylistSelection(playlist.id) - : () => _enterPlaylistSelectionMode(playlist.id), - ), - if (_isPlaylistSelectionMode) - Positioned( - left: 0, - top: 0, - right: 0, - child: IgnorePointer( - child: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ), - if (_isPlaylistSelectionMode) - Positioned( - top: 4, - right: 4, - child: IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.85), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - size: 16, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 16, height: 16), - ), - ), - ), - ], - ), - ); - }, - ); - } - } - - /// Build a collection item at [index] for the unified "All" tab list view. - /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. - Widget _buildAllTabListCollectionItem({ - required BuildContext context, - required ColorScheme colorScheme, - required int index, - required LibraryCollectionsState collectionState, - List filteredUnifiedItems = const [], - }) { - if (index == 0) { - return _buildCollectionListItem( - context: context, - colorScheme: colorScheme, - icon: Icons.add_circle_outline, - iconColor: Colors.white, - iconBgColor: const Color(0xFF1DB954), - title: context.l10n.collectionWishlist, - subtitle: - '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', - onTap: _openWishlistFolder, - ); - } else if (index == 1) { - return _buildCollectionListItem( - context: context, - colorScheme: colorScheme, - icon: Icons.favorite, - iconColor: Colors.white, - iconBgColor: const Color(0xFF8C67AC), - title: context.l10n.collectionLoved, - subtitle: - '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', - onTap: _openLovedFolder, - ); - } else { - final playlist = collectionState.playlists[index - 2]; - final isSelected = _selectedPlaylistIds.contains(playlist.id); - return DragTarget( - onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, - onAcceptWithDetails: (details) { - _onTrackDroppedOnPlaylist( - context, - details.data, - playlist.id, - playlist.name, - allItems: filteredUnifiedItems, - ); - }, - builder: (context, candidateData, rejectedData) { - final isHovering = candidateData.isNotEmpty; - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: isHovering - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.primary, width: 2), - color: colorScheme.primary.withValues(alpha: 0.1), - ) - : null, - child: Row( - children: [ - if (_isPlaylistSelectionMode) - GestureDetector( - onTap: () => _togglePlaylistSelection(playlist.id), - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - size: 18, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 18, height: 18), - ), - ), - ), - Expanded( - child: _buildCollectionListItem( + switch (entry.type) { + case _CollectionEntryType.wishlist: + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + count: collectionState.wishlistCount, + onTap: _openWishlistFolder, + ); + case _CollectionEntryType.loved: + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + count: collectionState.lovedCount, + onTap: _openLovedFolder, + ); + case _CollectionEntryType.playlist: + final playlist = collectionState.playlists[entry.playlistIndex]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.primary, width: 2), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : null, + child: Stack( + children: [ + _buildCollectionGridItem( context: context, colorScheme: colorScheme, coverWidget: _buildPlaylistCover( context, playlist, colorScheme, - 56, ), title: playlist.name, - subtitle: - '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + count: playlist.tracks.length, onTap: _isPlaylistSelectionMode ? () => _togglePlaylistSelection(playlist.id) : () => _openPlaylistById(playlist.id), @@ -3527,12 +3406,149 @@ class _QueueTabState extends ConsumerState { ? () => _togglePlaylistSelection(playlist.id) : () => _enterPlaylistSelectionMode(playlist.id), ), - ), - ], - ), - ); - }, - ); + if (_isPlaylistSelectionMode) + Positioned( + left: 0, + top: 0, + right: 0, + child: IgnorePointer( + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + if (_isPlaylistSelectionMode) + Positioned( + top: 4, + right: 4, + child: IgnorePointer( + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 20, + unselectedColor: colorScheme.surface.withValues( + alpha: 0.85, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + } + + /// Build a collection item for the unified "All" tab list view. + Widget _buildAllTabListCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required _CollectionEntry entry, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + switch (entry.type) { + case _CollectionEntryType.wishlist: + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', + onTap: _openWishlistFolder, + ); + case _CollectionEntryType.loved: + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', + onTap: _openLovedFolder, + ); + case _CollectionEntryType.playlist: + final playlist = collectionState.playlists[entry.playlistIndex]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.primary, width: 2), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : null, + child: Row( + children: [ + if (_isPlaylistSelectionMode) + GestureDetector( + onTap: () => _togglePlaylistSelection(playlist.id), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, + ), + ), + ), + Expanded( + child: _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + 56, + ), + title: playlist.name, + subtitle: + '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + ), + ], + ), + ); + }, + ); } } @@ -3564,32 +3580,14 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ Text( - '$totalTrackCount ${totalTrackCount == 1 ? 'track' : 'tracks'}', + context.l10n.queueTrackCount(totalTrackCount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const Spacer(), - // Filter button with long-press to reset if (!_isSelectionMode) - GestureDetector( - onLongPress: _activeFilterCount > 0 - ? _resetFilters - : null, - child: TextButton.icon( - onPressed: () => - _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ), + _buildFilterButton(context, unifiedItems), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( onPressed: () => _showCreatePlaylistDialog(context), @@ -3615,33 +3613,18 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ Text( - '$totalAlbumCount ${totalAlbumCount == 1 ? 'album' : 'albums'}', + context.l10n.queueAlbumCount(totalAlbumCount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const Spacer(), - GestureDetector( - onLongPress: _activeFilterCount > 0 ? _resetFilters : null, - child: TextButton.icon( - onPressed: () => _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ), + _buildFilterButton(context, unifiedItems), ], ), ), ), - // Albums empty state with filter button if (filteredGroupedAlbums.isEmpty && filteredGroupedLocalAlbums.isEmpty && filterMode == 'albums' && @@ -3652,21 +3635,7 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ const Spacer(), - GestureDetector( - onLongPress: _activeFilterCount > 0 ? _resetFilters : null, - child: TextButton.icon( - onPressed: () => _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ), + _buildFilterButton(context, unifiedItems), ], ), ), @@ -3677,7 +3646,7 @@ class _QueueTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - 'Downloaded', + context.l10n.queueDownloadedHeader, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), @@ -3701,7 +3670,7 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 12), Text( - 'Filtering...', + context.l10n.queueFilteringIndicator, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -3711,7 +3680,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Combined albums grid (downloaded + local in single grid) if (filterMode == 'albums' && (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty)) @@ -3726,7 +3694,6 @@ class _QueueTabState extends ConsumerState { ), delegate: SliverChildBuilderDelegate( (context, index) { - // First render downloaded albums, then local albums if (index < filteredGroupedAlbums.length) { final album = filteredGroupedAlbums[index]; return KeyedSubtree( @@ -3753,7 +3720,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Unified list/grid for 'all' filter: collection items + tracks combined if (filterMode == 'all') ...[ if (historyViewMode == 'grid') SliverPadding( @@ -3767,13 +3733,15 @@ class _QueueTabState extends ConsumerState { ), delegate: SliverChildBuilderDelegate( (context, index) { - final collectionCount = - 2 + collectionState.playlists.length; + final collectionEntries = _getVisibleCollectionEntries( + collectionState, + ); + final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabGridCollectionItem( context: context, colorScheme: colorScheme, - index: index, + entry: collectionEntries[index], collectionState: collectionState, filteredUnifiedItems: filteredUnifiedItems, ); @@ -3809,8 +3777,7 @@ class _QueueTabState extends ConsumerState { return const SizedBox.shrink(); }, childCount: - 2 + - collectionState.playlists.length + + _getVisibleCollectionEntries(collectionState).length + filteredUnifiedItems.length, ), ), @@ -3819,12 +3786,15 @@ class _QueueTabState extends ConsumerState { SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final collectionCount = 2 + collectionState.playlists.length; + final collectionEntries = _getVisibleCollectionEntries( + collectionState, + ); + final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabListCollectionItem( context: context, colorScheme: colorScheme, - index: index, + entry: collectionEntries[index], collectionState: collectionState, filteredUnifiedItems: filteredUnifiedItems, ); @@ -3860,14 +3830,12 @@ class _QueueTabState extends ConsumerState { return const SizedBox.shrink(); }, childCount: - 2 + - collectionState.playlists.length + + _getVisibleCollectionEntries(collectionState).length + filteredUnifiedItems.length, ), ), ], - // Singles filter - show unified items (downloaded + local singles) if (filterMode == 'singles') SliverToBoxAdapter( child: Padding( @@ -3875,31 +3843,14 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ Text( - '$totalTrackCount ${totalTrackCount == 1 ? 'track' : 'tracks'}', + context.l10n.queueTrackCount(totalTrackCount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), ), const Spacer(), if (!_isSelectionMode) - GestureDetector( - onLongPress: _activeFilterCount > 0 - ? _resetFilters - : null, - child: TextButton.icon( - onPressed: () => - _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ), + _buildFilterButton(context, unifiedItems), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( onPressed: () => _showCreatePlaylistDialog(context), @@ -4051,18 +4002,18 @@ class _QueueTabState extends ConsumerState { switch (filterMode) { case 'albums': - message = 'No album downloads'; - subtitle = 'Download multiple tracks from an album to see them here'; + message = context.l10n.queueEmptyAlbums; + subtitle = context.l10n.queueEmptyAlbumsSubtitle; icon = Icons.album; break; case 'singles': - message = 'No single downloads'; - subtitle = 'Single track downloads will appear here'; + message = context.l10n.queueEmptySingles; + subtitle = context.l10n.queueEmptySinglesSubtitle; icon = Icons.music_note; break; default: - message = 'No download history'; - subtitle = 'Downloaded tracks will appear here'; + message = context.l10n.queueEmptyHistory; + subtitle = context.l10n.queueEmptyHistorySubtitle; icon = Icons.history; } @@ -4095,121 +4046,47 @@ class _QueueTabState extends ConsumerState { _GroupedAlbum album, ColorScheme colorScheme, ) { - final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( - album.sampleFilePath, - ); - return Semantics( - button: true, - label: - 'Open album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks', - child: GestureDetector( - onTap: () => _navigateToDownloadedAlbum(album), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - cacheWidth: 300, - cacheHeight: 300, - errorBuilder: (context, error, stackTrace) => - Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, - ), - ), - ), - ) - : album.coverUrl != null - ? CachedNetworkImage( - imageUrl: album.coverUrl!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - memCacheWidth: 300, - memCacheHeight: 300, - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, - ), - ), - ), - ), - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note, - size: 12, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 4), - Text( - '${album.tracks.length}', - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), - ), - const SizedBox(height: 8), - Text( - album.albumName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - ), - ClickableArtistName( - artistName: album.artistName, - coverUrl: album.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + return ValueListenableBuilder( + valueListenable: _embeddedCoverVersion, + builder: (context, _, child) { + final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( + album.sampleFilePath, + ); + return _buildAlbumGridItemCore( + context: context, + albumName: album.albumName, + artistName: album.artistName, + trackCount: album.tracks.length, + colorScheme: colorScheme, + coverWidget: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + _albumPlaceholder(colorScheme), + ) + : album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + memCacheWidth: 300, + memCacheHeight: 300, + cacheManager: CoverCacheManager.instance, + ) + : null, + badgeColor: colorScheme.primaryContainer, + badgeTextColor: colorScheme.onPrimaryContainer, + badgeIcon: Icons.music_note, + coverUrl: album.coverUrl, + onTap: () => _navigateToDownloadedAlbum(album), + ); + }, ); } @@ -4219,12 +4096,58 @@ class _QueueTabState extends ConsumerState { _GroupedLocalAlbum album, ColorScheme colorScheme, ) { + return _buildAlbumGridItemCore( + context: context, + albumName: album.albumName, + artistName: album.artistName, + trackCount: album.tracks.length, + colorScheme: colorScheme, + coverWidget: album.coverPath != null + ? Image.file( + File(album.coverPath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + _albumPlaceholder(colorScheme), + ) + : null, + badgeColor: colorScheme.tertiaryContainer, + badgeTextColor: colorScheme.onTertiaryContainer, + badgeIcon: Icons.folder, + onTap: () => _navigateToLocalAlbum(album), + ); + } + + Widget _albumPlaceholder(ColorScheme colorScheme) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 48), + ), + ); + } + + Widget _buildAlbumGridItemCore({ + required BuildContext context, + required String albumName, + required String artistName, + required int trackCount, + required ColorScheme colorScheme, + required Widget? coverWidget, + required Color badgeColor, + required Color badgeTextColor, + required IconData badgeIcon, + required VoidCallback onTap, + String? coverUrl, + }) { return Semantics( button: true, - label: - 'Open local album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks', + label: 'Open album $albumName by $artistName, $trackCount tracks', child: GestureDetector( - onTap: () => _navigateToLocalAlbum(album), + onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -4233,38 +4156,8 @@ class _QueueTabState extends ConsumerState { children: [ ClipRRect( borderRadius: BorderRadius.circular(12), - child: album.coverPath != null - ? Image.file( - File(album.coverPath!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - cacheWidth: 300, - cacheHeight: 300, - errorBuilder: (context, error, stackTrace) => - Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, - ), - ), - ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, - ), - ), - ), + child: coverWidget ?? _albumPlaceholder(colorScheme), ), - // "Local" badge instead of track count Positioned( right: 8, bottom: 8, @@ -4274,22 +4167,18 @@ class _QueueTabState extends ConsumerState { vertical: 4, ), decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: badgeColor, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.folder, - size: 12, - color: colorScheme.onTertiaryContainer, - ), + Icon(badgeIcon, size: 12, color: badgeTextColor), const SizedBox(width: 4), Text( - '${album.tracks.length}', + '$trackCount', style: TextStyle( - color: colorScheme.onTertiaryContainer, + color: badgeTextColor, fontSize: 12, fontWeight: FontWeight.bold, ), @@ -4303,7 +4192,7 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(height: 8), Text( - album.albumName, + albumName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of( @@ -4311,7 +4200,8 @@ class _QueueTabState extends ConsumerState { ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), ClickableArtistName( - artistName: album.artistName, + artistName: artistName, + coverUrl: coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -4821,7 +4711,7 @@ class _QueueTabState extends ConsumerState { _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); - await showModalBottomSheet( + await showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( @@ -4994,15 +4884,12 @@ class _QueueTabState extends ConsumerState { for (final id in _selectedIds) { final item = itemsById[id]; if (item == null) continue; - // Detect format: use safFileName for download history SAF items, - // item.localItem?.format for local library items, file extension as fallback String nameToCheck; if (item.historyItem?.safFileName != null && item.historyItem!.safFileName!.isNotEmpty) { nameToCheck = item.historyItem!.safFileName!.toLowerCase(); } else if (item.localItem?.format != null && item.localItem!.format!.isNotEmpty) { - // Synthesize a fake extension to keep detection unified nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; } else { nameToCheck = item.filePath.toLowerCase(); @@ -5017,7 +4904,6 @@ class _QueueTabState extends ConsumerState { ? 'Opus' : null; if (ext == null || ext == targetFormat) continue; - // Skip lossy sources when target is lossless (pointless re-encoding) final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; if (isLosslessTarget && !isLosslessSource) continue; @@ -5033,7 +4919,6 @@ class _QueueTabState extends ConsumerState { return; } - // Confirm final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, @@ -5166,7 +5051,6 @@ class _QueueTabState extends ConsumerState { continue; } - // Handle SAF write-back if (isSaf && item.historyItem != null) { final hi = item.historyItem!; final treeUri = hi.downloadTreeUri; @@ -5219,12 +5103,10 @@ class _QueueTabState extends ConsumerState { continue; } - // Delete old SAF file try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} - // Update history await historyDb.updateFilePath( hi.id, safUri, @@ -5233,7 +5115,6 @@ class _QueueTabState extends ConsumerState { clearAudioSpecs: true, ); } - // Cleanup temp files try { await File(newPath).delete(); } catch (_) {} @@ -5243,7 +5124,6 @@ class _QueueTabState extends ConsumerState { } catch (_) {} } } else if (isSaf && item.localItem != null) { - // Local library SAF item: parse content URI to derive tree and dir final uri = Uri.parse(item.filePath); final pathSegments = uri.pathSegments; @@ -5320,7 +5200,6 @@ class _QueueTabState extends ConsumerState { await LibraryDatabase.instance.deleteByPath(item.filePath); } - // Cleanup temp files try { await File(newPath).delete(); } catch (_) {} @@ -5330,7 +5209,6 @@ class _QueueTabState extends ConsumerState { } catch (_) {} } } else if (item.historyItem != null) { - // Regular file - update history path await historyDb.updateFilePath( item.historyItem!.id, newPath, @@ -5338,17 +5216,13 @@ class _QueueTabState extends ConsumerState { clearAudioSpecs: true, ); } else if (item.localItem != null) { - // Regular local library file - delete old db entry, rescan picks up new file await LibraryDatabase.instance.deleteByPath(item.filePath); } successCount++; - } catch (_) { - // Continue to next item on error - } + } catch (_) {} } - // Reload history and local library to reflect path changes in UI ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); ref.read(localLibraryProvider.notifier).reloadFromStorage(); @@ -5370,7 +5244,6 @@ class _QueueTabState extends ConsumerState { } } - /// Bottom action bar for selection mode (Material Design 3 style) Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, @@ -5433,14 +5306,14 @@ class _QueueTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$selectedCount selected', + context.l10n.selectionSelected(selectedCount), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), Text( allSelected - ? 'All tracks selected' - : 'Tap tracks to select', + ? context.l10n.selectionAllSelected + : context.l10n.downloadedAlbumTapToSelect, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), @@ -5460,7 +5333,11 @@ class _QueueTabState extends ConsumerState { allSelected ? Icons.deselect : Icons.select_all, size: 20, ), - label: Text(allSelected ? 'Deselect' : 'Select All'), + label: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + ), style: TextButton.styleFrom( foregroundColor: colorScheme.primary, ), @@ -5470,7 +5347,6 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 12), - // Action buttons row: Share/Re-enrich, Convert, Delete Row( children: [ if (localOnlySelection && flacEligibleCount > 0) ...[ @@ -5528,7 +5404,7 @@ class _QueueTabState extends ConsumerState { label: Text( selectedCount > 0 ? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}' - : 'Select tracks to delete', + : context.l10n.selectionSelectToDelete, ), style: FilledButton.styleFrom( backgroundColor: selectedCount > 0 @@ -5557,101 +5433,160 @@ class _QueueTabState extends ConsumerState { ColorScheme colorScheme, ) { final isCompleted = item.status == DownloadStatus.completed; + final isActive = + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: InkWell( - onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - isCompleted - ? Hero( - tag: 'cover_${item.id}', - child: _buildCoverArt(item, colorScheme), - ) - : _buildCoverArt(item, colorScheme), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, + return Dismissible( + key: ValueKey('dismiss_${item.id}'), + direction: DismissDirection.endToStart, + confirmDismiss: isActive + ? (_) async { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cancel download?'), + content: Text( + 'This will cancel the active download for "${item.track.name}".', ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Keep'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Cancel'), + ), + ], ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.track.artistName, - artistId: item.track.artistId, - coverUrl: item.track.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (item.status == DownloadStatus.downloading) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: item.progress > 0 ? item.progress : null, - backgroundColor: - colorScheme.surfaceContainerHighest, - color: colorScheme.primary, - minHeight: 6, - ), - ), - ), - const SizedBox(width: 8), - Text( - // When progress is 0 (unknown size, e.g. YouTube tunnel mode), - // show bytes downloaded instead of percentage - item.progress > 0 - ? (item.speedMBps > 0 - ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : '${(item.progress * 100).toStringAsFixed(0)}%') - : (item.bytesReceived > 0 - ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : (item.speedMBps > 0 - ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : 'Starting...')), - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, + ) ?? + false; + } + : null, + onDismissed: (_) { + ref.read(downloadQueueProvider.notifier).dismissItem(item.id); + }, + background: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer), + ), + child: DownloadSuccessOverlay( + showSuccess: isCompleted, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: InkWell( + onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + isCompleted + ? Hero( + tag: 'cover_${item.id}', + child: _buildCoverArt(item, colorScheme), + ) + : _buildCoverArt(item, colorScheme), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.track.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: item.track.artistName, + artistId: item.track.artistId, + coverUrl: item.track.coverUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: item.progress > 0 + ? item.progress + : null, + backgroundColor: + colorScheme.surfaceContainerHighest, + color: colorScheme.primary, + minHeight: 6, + ), ), + ), + const SizedBox(width: 8), + Text( + item.bytesTotal > 0 && item.bytesReceived > 0 + ? (() { + final receivedMB = + item.bytesReceived / (1024 * 1024); + final totalMB = + item.bytesTotal / (1024 * 1024); + final progressLabel = item.progress > 0 + ? '${(item.progress * 100).toStringAsFixed(0)}% • ' + : ''; + final speedLabel = item.speedMBps > 0 + ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : ''; + return '$progressLabel${receivedMB.toStringAsFixed(1)} / ${totalMB.toStringAsFixed(1)} MB$speedLabel'; + })() + : (item.bytesReceived > 0 + ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}' + : (item.progress > 0 + ? (item.speedMBps > 0 + ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : '${(item.progress * 100).toStringAsFixed(0)}%') + : (item.speedMBps > 0 + ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : 'Starting...'))), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], ), ], - ), - ], - if (item.status == DownloadStatus.failed) ...[ - const SizedBox(height: 4), - Text( - item.errorMessage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.error, - ), - ), - ], - ], - ), + if (item.status == DownloadStatus.failed) ...[ + const SizedBox(height: 4), + Text( + item.errorMessage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: colorScheme.error), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + _buildActionButtons(context, item, colorScheme), + ], ), - const SizedBox(width: 8), - _buildActionButtons(context, item, colorScheme), - ], + ), ), ), ), @@ -5833,115 +5768,81 @@ class _QueueTabState extends ConsumerState { } } - /// Build cover image widget for unified library item - /// Supports network URLs (from downloads) and local file paths (from library scan) - Widget _buildUnifiedCoverImage( - UnifiedLibraryItem item, - ColorScheme colorScheme, - double size, + /// Reusable filter button with badge showing active filter count. + Widget _buildFilterButton( + BuildContext context, + List unifiedItems, ) { - final isDownloaded = item.source == LibraryItemSource.downloaded; - if (isDownloaded) { - final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( - item.filePath, - ); - if (embeddedCoverPath != null) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(embeddedCoverPath), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: (size * 2).toInt(), - cacheHeight: (size * 2).toInt(), - errorBuilder: (context, error, stackTrace) => - _buildPlaceholderCover(colorScheme, size, isDownloaded), - ), - ); - } - } - - // Network URL cover (downloaded items) - if (item.coverUrl != null) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: (size * 2).toInt(), - memCacheHeight: (size * 2).toInt(), - cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( - width: size, - height: size, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), - errorWidget: (context, url, error) => Container( - width: size, - height: size, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), + return GestureDetector( + onLongPress: _activeFilterCount > 0 ? _resetFilters : null, + child: TextButton.icon( + onPressed: () => _showFilterSheet(context, unifiedItems), + icon: Badge( + isLabelVisible: _activeFilterCount > 0, + label: Text('$_activeFilterCount'), + child: const Icon(Icons.filter_list, size: 18), ), - ); - } - - // Local file cover (from library scan) - if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(item.localCoverPath!), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: (size * 2).toInt(), - cacheHeight: (size * 2).toInt(), - errorBuilder: (context, error, stackTrace) => - _buildPlaceholderCover(colorScheme, size, isDownloaded), - ), - ); - } - - // Placeholder (no cover) - return _buildPlaceholderCover(colorScheme, size, isDownloaded); - } - - /// Build placeholder cover image - Widget _buildPlaceholderCover( - ColorScheme colorScheme, - double size, - bool isDownloaded, - ) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: isDownloaded - ? colorScheme.surfaceContainerHighest - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.music_note, - color: isDownloaded - ? colorScheme.onSurfaceVariant - : colorScheme.onSecondaryContainer, - size: size * 0.4, + label: Text(context.l10n.libraryFilterTitle), + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), ), ); } - /// Build cover image for unified grid item (fills container) - Widget _buildUnifiedGridCoverImage( + /// Build cover image widget for unified library item. + /// When [size] is provided, renders at fixed dimensions (list mode). + /// When [size] is null, fills the parent container (grid mode). + Widget _buildUnifiedCoverImage( + UnifiedLibraryItem item, + ColorScheme colorScheme, [ + double? size, + ]) { + final isDownloaded = item.source == LibraryItemSource.downloaded; + + // For downloaded items, listen to embedded cover version so the cover + // updates after async extraction completes. + if (isDownloaded) { + return ValueListenableBuilder( + valueListenable: _embeddedCoverVersion, + builder: (context, _, child) => + _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size), + ); + } + + return _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size); + } + + Widget _buildUnifiedCoverImageInner( UnifiedLibraryItem item, ColorScheme colorScheme, - ) { - final isDownloaded = item.source == LibraryItemSource.downloaded; + bool isDownloaded, [ + double? size, + ]) { + final cacheSize = size != null ? (size * 2).toInt() : 200; + final iconSize = size != null ? size * 0.4 : 32.0; + + Widget buildPlaceholder({bool isLocal = false}) { + final bgColor = (isDownloaded && !isLocal) + ? colorScheme.surfaceContainerHighest + : colorScheme.secondaryContainer; + final fgColor = (isDownloaded && !isLocal) + ? colorScheme.onSurfaceVariant + : colorScheme.onSecondaryContainer; + return Container( + width: size, + height: size, + decoration: size != null + ? BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + ) + : null, + color: size != null ? null : bgColor, + child: Center( + child: Icon(Icons.music_note, color: fgColor, size: iconSize), + ), + ); + } + if (isDownloaded) { final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( item.filePath, @@ -5951,17 +5852,12 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(8), child: Image.file( File(embeddedCoverPath), + width: size, + height: size, fit: BoxFit.cover, - cacheWidth: 200, - cacheHeight: 200, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), + cacheWidth: cacheSize, + cacheHeight: cacheSize, + errorBuilder: (context, error, stackTrace) => buildPlaceholder(), ), ); } @@ -5973,26 +5869,14 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( imageUrl: item.coverUrl!, + width: size, + height: size, fit: BoxFit.cover, - memCacheWidth: 200, - memCacheHeight: 200, + memCacheWidth: cacheSize, + memCacheHeight: cacheSize, cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), - errorWidget: (context, url, error) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), + placeholder: (context, url) => buildPlaceholder(), + errorWidget: (context, url, error) => buildPlaceholder(), ), ); } @@ -6003,36 +5887,24 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(8), child: Image.file( File(item.localCoverPath!), + width: size, + height: size, fit: BoxFit.cover, - cacheWidth: 200, - cacheHeight: 200, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.secondaryContainer, - child: Icon( - Icons.music_note, - color: colorScheme.onSecondaryContainer, - size: 32, - ), - ), + cacheWidth: cacheSize, + cacheHeight: cacheSize, + errorBuilder: (context, error, stackTrace) => + buildPlaceholder(isLocal: true), ), ); } // Placeholder (no cover) + if (size != null) { + return buildPlaceholder(); + } return ClipRRect( borderRadius: BorderRadius.circular(8), - child: Container( - color: isDownloaded - ? colorScheme.surfaceContainerHighest - : colorScheme.secondaryContainer, - child: Icon( - Icons.music_note, - color: isDownloaded - ? colorScheme.onSurfaceVariant - : colorScheme.onSecondaryContainer, - size: 32, - ), - ), + child: buildPlaceholder(), ); } @@ -6059,193 +5931,185 @@ class _QueueTabState extends ConsumerState { ? colorScheme.onPrimaryContainer : colorScheme.onSecondaryContainer; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - child: InkWell( - onTap: _isSelectionMode - ? () => _toggleSelection(item.id) - : isDownloaded - ? () => _navigateToHistoryMetadataScreen(item.historyItem!) - : item.localItem != null - ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: item.coverUrl ?? item.localCoverPath ?? '', - ), - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(item.id), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, + return Semantics( + label: '${item.trackName} by ${item.artistName}', + selected: isSelected, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, + child: InkWell( + onTap: _isSelectionMode + ? () => _toggleSelection(item.id) + : isDownloaded + ? () => _navigateToHistoryMetadataScreen(item.historyItem!) + : item.localItem != null + ? () => _navigateToLocalMetadataScreen(item.localItem!) + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(item.id), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + if (_isSelectionMode) ...[ + Semantics( + checked: isSelected, + label: isSelected ? 'Deselect track' : 'Select track', + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + const SizedBox(width: 12), + ], + Hero( + tag: 'cover_lib_${item.id}', + child: _buildUnifiedCoverImage(item, colorScheme, 56), ), const SizedBox(width: 12), - ], - // Cover image - supports network URL and local file path - _buildUnifiedCoverImage(item, colorScheme, 56), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.artistName, - coverUrl: item.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - // Source badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: sourceColor, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - sourceLabel, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: sourceTextColor, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), - const SizedBox(width: 8), - Flexible( - child: Text( - dateStr, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSurfaceVariant - .withValues(alpha: 0.7), - ), - ), + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - if (item.quality != null && - item.quality!.isNotEmpty) ...[ - const SizedBox(width: 8), + ), + const SizedBox(height: 2), + Row( + children: [ Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.tertiaryContainer - : colorScheme.surfaceContainerHighest, + color: sourceColor, borderRadius: BorderRadius.circular(4), ), child: Text( - item.quality!, + sourceLabel, style: Theme.of(context).textTheme.labelSmall ?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onTertiaryContainer - : colorScheme.onSurfaceVariant, + color: sourceTextColor, fontSize: 10, fontWeight: FontWeight.w500, ), ), ), - ], - ], - ), - ], - ), - ), - const SizedBox(width: 8), - - if (!_isSelectionMode) - ValueListenableBuilder( - valueListenable: fileExistsListenable, - builder: (context, fileExists, child) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (fileExists) - IconButton( - onPressed: () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: - item.coverUrl ?? item.localCoverPath ?? '', + const SizedBox(width: 8), + Flexible( + child: Text( + dateStr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), + ), ), - icon: Icon( - Icons.play_arrow, - color: colorScheme.primary, - ), - tooltip: context.l10n.tooltipPlay, - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer - .withValues(alpha: 0.3), - ), - ) - else - Icon( - Icons.error_outline, - color: colorScheme.error, - size: 20, ), - ], - ); - }, + if (item.quality != null && + item.quality!.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: item.quality!.startsWith('24') + ? colorScheme.tertiaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.quality!, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiaryContainer + : colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ], + ), ), - ], + const SizedBox(width: 8), + + if (!_isSelectionMode) + ValueListenableBuilder( + valueListenable: fileExistsListenable, + builder: (context, fileExists, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (fileExists) + IconButton( + onPressed: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? item.localCoverPath ?? '', + ), + icon: Icon( + Icons.play_arrow, + color: colorScheme.primary, + ), + tooltip: context.l10n.tooltipPlay, + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer + .withValues(alpha: 0.3), + ), + ) + else + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ], + ); + }, + ), + ], + ), ), ), ), @@ -6286,9 +6150,8 @@ class _QueueTabState extends ConsumerState { children: [ AspectRatio( aspectRatio: 1, - child: _buildUnifiedGridCoverImage(item, colorScheme), + child: _buildUnifiedCoverImage(item, colorScheme), ), - // Source badge (top-right) Positioned( right: 4, top: 4, @@ -6312,7 +6175,6 @@ class _QueueTabState extends ConsumerState { ), ), ), - // Quality badge (top-left) if (item.quality != null && item.quality!.isNotEmpty) Positioned( left: 4, @@ -6479,6 +6341,20 @@ class _QueueItemSliverRow extends ConsumerWidget { } } +enum _CollectionEntryType { wishlist, loved, playlist } + +class _CollectionEntry { + final _CollectionEntryType type; + final int playlistIndex; + + const _CollectionEntry._(this.type, [this.playlistIndex = -1]); + + static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist); + static const loved = _CollectionEntry._(_CollectionEntryType.loved); + static _CollectionEntry playlist(int index) => + _CollectionEntry._(_CollectionEntryType.playlist, index); +} + class _FilterChip extends StatelessWidget { final String label; final int count; @@ -6496,52 +6372,36 @@ class _FilterChip extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Material( - color: isSelected - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(20), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), + return FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) + : colorScheme.outline.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 11, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) - : colorScheme.outline.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - count.toString(), - style: TextStyle( - fontSize: 11, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ), - ], + ), ), - ), + ], ), + selected: isSelected, + onSelected: (_) => onTap(), + showCheckmark: false, ); } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 42118769..0268e149 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class SearchScreen extends ConsumerStatefulWidget { @@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState { ref .read(downloadQueueProvider.notifier) .addToQueue(track, settings.defaultService); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); } @override @@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState { child: Text(error, style: TextStyle(color: colorScheme.error)), ), Expanded( - child: tracks.isEmpty - ? _buildEmptyState(colorScheme) - : ListView.builder( - itemCount: tracks.length, - itemBuilder: (context, index) => - _buildTrackTile(tracks[index], colorScheme), - ), + child: AnimatedStateSwitcher( + child: isLoading && tracks.isEmpty + ? const TrackListSkeleton(key: ValueKey('loading')) + : tracks.isEmpty + ? _buildEmptyState(colorScheme) + : ListView.builder( + key: const ValueKey('results'), + itemCount: tracks.length, + itemBuilder: (context, index) => StaggeredListItem( + index: index, + child: _buildTrackTile(tracks[index], colorScheme), + ), + ), + ), ), ], ), @@ -127,32 +135,30 @@ class _SearchScreenState extends ConsumerState { } Widget _buildTrackTile(Track track, ColorScheme colorScheme) { - return ListTile( - leading: track.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - cacheManager: CoverCacheManager.instance, - ), - ) - : Container( + final coverWidget = track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: track.coverUrl!, width: 48, height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), + fit: BoxFit.cover, + memCacheWidth: 144, + memCacheHeight: 144, + cacheManager: CoverCacheManager.instance, ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ); + return ListTile( + leading: coverWidget, title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 4884328a..75d93e6e 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -770,7 +770,7 @@ class _LanguageSelector extends StatelessWidget { void _showLanguagePicker(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surface, diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 5ba2fcce..16b629c4 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -477,122 +477,40 @@ class _CryptoWalletItem extends StatelessWidget { } } -int _cr(String v) { - int r = 0x1F; - for (final c in v.codeUnits) { - r = (r * 31 + c) & 0x7FFFFFFF; - } - return r; -} - -// Highlighted supporters (hashes of names). -const _cv = {1211573191, 1003219236}; - -// Diamond tier supporters ($50+ donors). -const _dv = {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 { +class _SupporterChip extends StatelessWidget { final String name; final ColorScheme colorScheme; const _SupporterChip({required this.name, required this.colorScheme}); - @override - State<_SupporterChip> createState() => _SupporterChipState(); -} - -class _SupporterChipState extends State<_SupporterChip> - with SingleTickerProviderStateMixin { - late final _SupporterTier _tier; - AnimationController? _shimmerController; - - @override - void initState() { - super.initState(); - _tier = _tierOf(widget.name); - if (_tier == _SupporterTier.diamond) { - _shimmerController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2400), - )..repeat(); - } - } - - @override - void dispose() { - _shimmerController?.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - if (_tier == _SupporterTier.diamond) { - return _buildDiamondChip(isDark); - } - - final isGold = _tier == _SupporterTier.gold; - const goldChipColor = Color(0xFFFFF8DC); - const goldAccentColor = Color(0xFFB8860B); - const goldDarkChipColor = Color(0xFF3A3000); - - final chipColor = isGold - ? goldChipColor - : widget.colorScheme.secondaryContainer; - final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary; - final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor; - return Material( - color: effectiveChipColor, + color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20), - child: Container( - decoration: isGold - ? BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: accentColor.withValues(alpha: 0.4), - width: 1, - ), - ) - : null, + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ CircleAvatar( radius: 10, - backgroundColor: accentColor.withValues(alpha: 0.2), - child: isGold - ? Icon(Icons.star_rounded, size: 12, color: accentColor) - : Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: accentColor, - ), - ), + backgroundColor: colorScheme.primary.withValues(alpha: 0.2), + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), ), const SizedBox(width: 8), Text( - widget.name, + name, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: isGold - ? accentColor - : widget.colorScheme.onSecondaryContainer, - fontWeight: isGold ? FontWeight.w600 : FontWeight.w500, + color: colorScheme.onSecondaryContainer, + fontWeight: 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 { diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 85470e99..39b116e6 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -465,34 +465,6 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), ], - SettingsItem( - title: context.l10n.youtubeOpusBitrateTitle, - subtitle: - '${settings.youtubeOpusBitrate}kbps (128/256/320)', - onTap: () => _showYoutubeBitratePicker( - context: context, - title: context.l10n.youtubeOpusBitrateTitle, - currentValue: settings.youtubeOpusBitrate, - options: const [128, 256, 320], - onSave: (value) => ref - .read(settingsProvider.notifier) - .setYoutubeOpusBitrate(value), - ), - ), - SettingsItem( - title: context.l10n.youtubeMp3BitrateTitle, - subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)', - onTap: () => _showYoutubeBitratePicker( - context: context, - title: context.l10n.youtubeMp3BitrateTitle, - currentValue: settings.youtubeMp3Bitrate, - options: const [128, 256, 320], - onSave: (value) => ref - .read(settingsProvider.notifier) - .setYoutubeMp3Bitrate(value), - ), - showDivider: false, - ), ], ), ), @@ -538,7 +510,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), onTap: () => Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (_) => const LyricsProviderPriorityPage(), ), ), @@ -869,6 +841,8 @@ class _DownloadSettingsPageState extends ConsumerState { return 'Albums/[Year] Album/'; case 'artist_album_singles': return 'Artist/Album/ + Artist/Singles/'; + case 'artist_album_flat': + return 'Artist/Album/ + Artist/song.flac'; default: return 'Albums/Artist/Album Name/'; } @@ -879,7 +853,7 @@ class _DownloadSettingsPageState extends ConsumerState { WidgetRef ref, String current, ) { - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, builder: (context) => SafeArea( @@ -958,6 +932,20 @@ class _DownloadSettingsPageState extends ConsumerState { Navigator.pop(context); }, ), + ListTile( + leading: const Icon(Icons.person_outline_outlined), + title: Text(context.l10n.albumFolderArtistAlbumFlat), + subtitle: Text(context.l10n.albumFolderArtistAlbumFlatSubtitle), + trailing: current == 'artist_album_flat' + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setAlbumFolderStructure('artist_album_flat'); + Navigator.pop(context); + }, + ), ], ), ), @@ -1014,7 +1002,7 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, @@ -1232,7 +1220,7 @@ class _DownloadSettingsPageState extends ConsumerState { final settings = ref.read(settingsProvider); final isSafMode = settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1310,7 +1298,7 @@ class _DownloadSettingsPageState extends ConsumerState { void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1505,7 +1493,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1610,7 +1598,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1689,68 +1677,6 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - void _showYoutubeBitratePicker({ - required BuildContext context, - required String title, - required int currentValue, - required List options, - required void Function(int value) onSave, - }) { - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 8), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(sheetContext).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - for (final bitrate in options) - ListTile( - title: Text('$bitrate kbps'), - trailing: bitrate == currentValue - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - onSave(bitrate); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ); - } - void _showMusixmatchLanguagePicker( BuildContext context, WidgetRef ref, @@ -1759,7 +1685,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final controller = TextEditingController(text: currentLanguage); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1845,7 +1771,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1917,7 +1843,7 @@ class _DownloadSettingsPageState extends ConsumerState { ) { final colorScheme = Theme.of(context).colorScheme; final normalizedCurrent = current.trim().toUpperCase(); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1985,7 +1911,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -2100,7 +2026,7 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube']; + final builtInServiceIds = ['tidal', 'qobuz', 'deezer']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) @@ -2136,15 +2062,6 @@ class _ServiceSelector extends ConsumerWidget { onTap: () => onChanged('qobuz'), ), ), - const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.smart_display, - label: 'YouTube', - isSelected: effectiveService == 'youtube', - onTap: () => onChanged('youtube'), - ), - ), ], ), if (extensionProviders.isNotEmpty) ...[ diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index c6e00b55..48d23d0d 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState { final hasError = extension.status == 'error'; return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ @@ -832,9 +832,9 @@ class _SettingItemState extends State<_SettingItem> { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } finally { if (mounted) { @@ -849,7 +849,7 @@ class _SettingItemState extends State<_SettingItem> { ); final colorScheme = Theme.of(context).colorScheme; - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Text(widget.setting.label), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 57979689..9fec23ee 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -61,7 +62,7 @@ class _ExtensionsPageState extends ConsumerState { final topPadding = normalizedHeaderTopPadding(context); return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ @@ -212,7 +213,7 @@ class _ExtensionsPageState extends ConsumerState { showDivider: index < extState.extensions.length - 1, onTap: () => Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (_) => ExtensionDetailPage(extensionId: ext.id), ), @@ -469,7 +470,9 @@ class _DownloadPriorityItem extends ConsumerWidget { onTap: hasDownloadExtensions ? () => Navigator.push( context, - MaterialPageRoute(builder: (_) => const ProviderPriorityPage()), + MaterialPageRoute( + builder: (_) => const ProviderPriorityPage(), + ), ) : null, child: Padding( @@ -534,7 +537,7 @@ class _MetadataPriorityItem extends ConsumerWidget { onTap: hasMetadataExtensions ? () => Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (_) => const MetadataProviderPriorityPage(), ), ) @@ -600,14 +603,12 @@ class _SearchProviderSelector extends ConsumerWidget { .where((e) => e.enabled && e.hasCustomSearch) .toList(); - // Always allow tapping: built-in providers are always available final hasAnyProvider = searchProviders.isNotEmpty || _builtInProviders.isNotEmpty; String currentProviderName = context.l10n.extensionDefaultProvider; if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { - // Check built-in first if (_builtInProviders.containsKey(settings.searchProvider)) { currentProviderName = _builtInProviders[settings.searchProvider]!; } else { @@ -680,12 +681,12 @@ class _SearchProviderSelector extends ConsumerWidget { void _showSearchProviderPicker( BuildContext context, WidgetRef ref, - dynamic settings, + AppSettings settings, List searchProviders, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -861,12 +862,12 @@ class _HomeFeedProviderSelector extends ConsumerWidget { void _showHomeFeedProviderPicker( BuildContext context, WidgetRef ref, - dynamic settings, + AppSettings settings, List homeFeedProviders, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 86c461a3..83014ec9 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -23,21 +23,15 @@ class _LibrarySettingsPageState extends ConsumerState { int _androidSdkVersion = 0; bool _hasStoragePermission = false; - /// Convert SAF content URI to a readable display path String _getDisplayPath(String path) { if (!path.startsWith('content://')) return path; - // Extract the path portion from SAF tree URI - // e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic - // -> /storage/emulated/0/Music try { final uri = Uri.parse(path); - final treePath = - uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic" + final treePath = uri.pathSegments.last; final decoded = Uri.decodeComponent(treePath); if (decoded.startsWith('primary:')) { return '/storage/emulated/0/${decoded.substring('primary:'.length)}'; } - // For SD card or other volumes, just show the decoded path return decoded; } catch (_) { return path; @@ -261,7 +255,7 @@ class _LibrarySettingsPageState extends ConsumerState { void _showAutoScanPicker(BuildContext context, String current) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 611db06f..06c0173b 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -92,7 +92,7 @@ class _LogScreenState extends State { } void _clearLogs() { - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.logClearLogsTitle), @@ -136,7 +136,7 @@ class _LogScreenState extends State { final logs = _filteredLogs; return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( controller: _scrollController, diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 3f5a2e7c..30fbb798 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -19,7 +19,7 @@ class OptionsSettingsPage extends ConsumerWidget { final topPadding = normalizedHeaderTopPadding(context); return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ @@ -241,7 +241,7 @@ class OptionsSettingsPage extends ConsumerWidget { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.dialogClearHistoryTitle), @@ -273,7 +273,7 @@ class OptionsSettingsPage extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - showDialog( + showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index 02c15b1a..3d73a06d 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -340,12 +340,6 @@ class _ProviderItem extends StatelessWidget { icon: Icons.graphic_eq, isBuiltIn: true, ); - case 'youtube': - return _ProviderInfo( - name: 'YouTube', - icon: Icons.play_circle_outline, - isBuiltIn: true, - ); default: return _ProviderInfo( name: provider, diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 7e66fc62..f08d967a 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -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/utils/app_bar_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class SettingsTab extends ConsumerWidget { const SettingsTab({super.key}); @@ -150,26 +151,6 @@ class SettingsTab extends ConsumerWidget { void _navigateTo(BuildContext context, Widget page) { FocusManager.instance.primaryFocus?.unfocus(); - - 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), - ), - ); + Navigator.of(context).push(slidePageRoute(page: page)); } } diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 5f137482..9a9ddd9f 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -124,7 +124,7 @@ class _SetupScreenState extends ConsumerState { final shouldOpen = await _showAndroid11StorageDialog(); if (shouldOpen == true) { await Permission.manageExternalStorage.request(); - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); manageStatus = await Permission.manageExternalStorage.status; } } @@ -203,7 +203,7 @@ class _SetupScreenState extends ConsumerState { } Future _showPermissionDeniedDialog(String permissionType) async { - await showDialog( + await showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.setupPermissionRequired(permissionType)), @@ -286,7 +286,7 @@ class _SetupScreenState extends ConsumerState { Future _showIOSDirectoryOptions() async { final colorScheme = Theme.of(context).colorScheme; - await showModalBottomSheet( + await showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -441,14 +441,9 @@ class _SetupScreenState extends ConsumerState { void _nextPage() { bool canProceed = false; - // Step 0 is Welcome, always can proceed if (_currentStep == 0) { canProceed = true; } else { - // Logic for other steps (offset by 1 because of welcome step) - // Step 1: Storage - // Step 2: Notification (if android 13+) OR Directory - // etc. canProceed = _isStepCompleted(_currentStep); } @@ -470,9 +465,8 @@ class _SetupScreenState extends ConsumerState { } 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; if (_androidSdkVersion >= 33) { diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 63256832..8270bec3 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -4,6 +4,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -58,7 +59,9 @@ class _StoreTabState extends ConsumerState { final downloadingId = ref.watch( storeProvider.select((s) => s.downloadingId), ); - final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl)); + final hasRegistryUrl = ref.watch( + storeProvider.select((s) => s.hasRegistryUrl), + ); final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl)); final filteredExtensions = StoreState( extensions: extensions, @@ -139,7 +142,7 @@ class _StoreTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: value.text.isNotEmpty ? IconButton( - tooltip: 'Clear search', + tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -151,23 +154,37 @@ class _StoreTabState extends ConsumerState { : null, border: OutlineInputBorder( 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, - fillColor: - Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHighest, + fillColor: colorScheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + horizontal: 20, + vertical: 16, ), ), 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 { _CategoryChip( label: context.l10n.storeFilterIntegration, icon: Icons.link, - isSelected: selectedCategory == StoreCategory.integration, + isSelected: + selectedCategory == StoreCategory.integration, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.integration), @@ -242,8 +260,11 @@ class _StoreTabState extends ConsumerState { ), if (isLoading && extensions.isEmpty) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 6), + ), ) else if (error != null && extensions.isEmpty) SliverFillRemaining(child: _buildErrorState(error, colorScheme)) @@ -309,9 +330,9 @@ class _StoreTabState extends ConsumerState { const SizedBox(height: 24), Text( context.l10n.storeAddRepoTitle, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 32), @@ -322,16 +343,23 @@ class _StoreTabState extends ConsumerState { labelText: context.l10n.storeRepoUrlLabel, prefixIcon: const Icon(Icons.link), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(28), borderSide: BorderSide(color: colorScheme.primary, width: 2), ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), ), keyboardType: TextInputType.url, autocorrect: false, @@ -347,7 +375,11 @@ class _StoreTabState extends ConsumerState { ), child: Row( children: [ - Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer), + Icon( + Icons.error_outline, + size: 20, + color: colorScheme.onErrorContainer, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -384,7 +416,7 @@ class _StoreTabState extends ConsumerState { void _showChangeRepoDialog(String currentUrl) { final changeUrlController = TextEditingController(text: currentUrl); - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.storeRepoDialogTitle), @@ -416,7 +448,31 @@ class _StoreTabState extends ConsumerState { labelText: context.l10n.storeNewRepoUrlLabel, prefixIcon: const Icon(Icons.link), 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, @@ -503,7 +559,9 @@ class _StoreTabState extends ConsumerState { ), const SizedBox(height: 16), Text( - hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions, + hasFilters + ? context.l10n.storeEmptyNoResults + : context.l10n.storeEmptyNoExtensions, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -525,7 +583,7 @@ class _StoreTabState extends ConsumerState { void _showExtensionDetails(StoreExtension ext) { Navigator.of(context).push( - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionDetailsScreen(extension: ext), ), ); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 09f0175c..0e3538a8 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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/mime_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; final _log = AppLogger('TrackMetadata'); @@ -59,19 +60,19 @@ class _TrackMetadataScreenState extends ConsumerState { bool _fileExists = false; bool _hasCheckedFile = false; int? _fileSize; - String? _lyrics; // Cleaned lyrics for display (no timestamps) - String? _rawLyrics; // Raw LRC with timestamps for embedding + String? _lyrics; + String? _rawLyrics; bool _lyricsLoading = false; String? _lyricsError; String? _lyricsSource; bool _showTitleInAppBar = false; bool _lyricsEmbedded = false; - bool _isEmbedding = false; // Track embed operation in progress + bool _isEmbedding = false; bool _isInstrumental = false; - bool _isConverting = false; // Track convert operation in progress + bool _isConverting = false; bool _hasMetadataChanges = false; bool _hasLoadedResolvedAudioMetadata = false; - Map? _editedMetadata; // Overrides after metadata edit + Map? _editedMetadata; String? _embeddedCoverPreviewPath; final ScrollController _scrollController = ScrollController(); static final RegExp _lrcTimestampPattern = RegExp( @@ -307,12 +308,10 @@ class _TrackMetadataScreenState extends ConsumerState { storedQuality: _quality, ); - // Fill in album name from file tags if stored value is empty final needsAlbum = resolvedAlbum != null && resolvedAlbum.isNotEmpty && (albumName.isEmpty); - // Fill in duration from file if stored value is missing/zero final needsDuration = resolvedDuration != null && resolvedDuration > 0 && @@ -519,6 +518,8 @@ class _TrackMetadataScreenState extends ConsumerState { String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; + String get _coverHeroTag => + _isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId'; String? get _coverUrl => _isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl); String? get _localCoverPath => @@ -527,7 +528,6 @@ class _TrackMetadataScreenState extends ConsumerState { String get _service => _isLocalItem ? 'local' : _downloadItem!.service; DateTime get _addedAt { if (_isLocalItem) { - // Use file modification time if available, otherwise fall back to scannedAt final modTime = _localLibraryItem!.fileModTime; if (modTime != null && modTime > 0) { return DateTime.fromMillisecondsSinceEpoch(modTime); @@ -577,7 +577,6 @@ class _TrackMetadataScreenState extends ConsumerState { String get cleanFilePath { var path = _filePath; if (path.startsWith('EXISTS:')) path = path.substring(7); - // Strip CUE virtual path suffix for filesystem operations if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path); return path; } @@ -770,6 +769,11 @@ class _TrackMetadataScreenState extends ConsumerState { _buildLyricsCard(context, colorScheme), + if (_fileExists) ...[ + const SizedBox(height: 16), + AudioAnalysisCard(filePath: _filePath), + ], + const SizedBox(height: 24), _buildActionButtons(context, ref, colorScheme, _fileExists), @@ -790,38 +794,42 @@ class _TrackMetadataScreenState extends ConsumerState { double expandedHeight, bool showContent, ) { - return Stack( - fit: StackFit.expand, - children: [ - if (_hasPath(_embeddedCoverPreviewPath)) - Image.file( + final coverChild = _hasPath(_embeddedCoverPreviewPath) + ? Image.file( File(_embeddedCoverPreviewPath!), fit: BoxFit.cover, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) - else if (_coverUrl != null) - CachedNetworkImage( + : _coverUrl != null + ? CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), ) - else if (_localCoverPath != null && _localCoverPath!.isNotEmpty) - Image.file( + : _localCoverPath != null && _localCoverPath!.isNotEmpty + ? Image.file( File(_localCoverPath!), fit: BoxFit.cover, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) - else - Container( + : Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.music_note, size: 80, color: colorScheme.onSurfaceVariant, ), - ), + ); + + return Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: _coverHeroTag, + child: Material(color: Colors.transparent, child: coverChild), + ), Positioned( left: 0, right: 0, @@ -1614,7 +1622,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - // Show "Embed Lyrics" button if lyrics are from online (not already embedded) if (!_lyricsEmbedded && _fileExists) ...[ const SizedBox(height: 16), Center( @@ -1662,7 +1669,6 @@ class _TrackMetadataScreenState extends ConsumerState { try { final durationMs = (duration ?? 0) * 1000; - // First, check if lyrics are embedded in the file if (_fileExists) { final embeddedResult = await PlatformBridge.getLyricsLRCWithSource( @@ -1696,12 +1702,11 @@ class _TrackMetadataScreenState extends ConsumerState { } } - // No embedded lyrics, fetch from online final result = await PlatformBridge.getLyricsLRCWithSource( _spotifyId ?? '', trackName, artistName, - filePath: null, // Don't check file again + filePath: null, durationMs: durationMs, ).timeout(const Duration(seconds: 20)); @@ -1727,9 +1732,9 @@ class _TrackMetadataScreenState extends ConsumerState { final cleanLyrics = _cleanLrcForDisplay(lrcText); setState(() { _lyrics = cleanLyrics; - _rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding + _rawLyrics = lrcText; _lyricsSource = source.isNotEmpty ? source : null; - _lyricsEmbedded = false; // Lyrics from online, not embedded + _lyricsEmbedded = false; _lyricsLoading = false; }); } @@ -1756,7 +1761,6 @@ class _TrackMetadataScreenState extends ConsumerState { setState(() => _isEmbedding = true); - // Capture l10n strings before async gaps to avoid use_build_context_synchronously final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage; final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics; final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat; @@ -1986,7 +1990,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Write temp file to SAF tree final treeUri = _downloadItem?.downloadTreeUri; final relativeDir = _downloadItem?.safRelativeDir ?? ''; if (treeUri != null && treeUri.isNotEmpty) { @@ -2033,7 +2036,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Regular file path final dir = _getFileDirectory(); final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg'; @@ -2126,7 +2128,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Write temp file to SAF tree final treeUri = _downloadItem?.downloadTreeUri; final relativeDir = _downloadItem?.safRelativeDir ?? ''; if (treeUri != null && treeUri.isNotEmpty) { @@ -2134,7 +2135,7 @@ class _TrackMetadataScreenState extends ConsumerState { treeUri: treeUri, relativeDir: relativeDir, fileName: '$baseName.lrc', - mimeType: 'text/plain', + mimeType: 'application/octet-stream', srcPath: tempOutput, ); try { @@ -2182,7 +2183,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Regular file path final dir = _getFileDirectory(); final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc'; @@ -2257,7 +2257,6 @@ class _TrackMetadataScreenState extends ConsumerState { final result = await PlatformBridge.reEnrichFile(request); final method = result['method'] as String?; - // Update local UI state with enriched metadata from online search final enriched = result['enriched_metadata'] as Map?; if (enriched != null && mounted) { setState(() { @@ -2344,7 +2343,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - // For SAF files, copy processed temp file back if (ffmpegResult != null && tempPath != null && safUri != null) { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); if (!ok && mounted) { @@ -2357,7 +2355,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ); - // Cleanup temp files if (_hasPath(downloadedCoverPath)) { try { await File(downloadedCoverPath!).delete(); @@ -2375,7 +2372,6 @@ class _TrackMetadataScreenState extends ConsumerState { } } - // Cleanup temp files if (tempPath != null && tempPath.isNotEmpty) { try { await File(tempPath).delete(); @@ -2397,7 +2393,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - // Cleanup temp cover from Go backend if (_hasPath(downloadedCoverPath)) { try { await File(downloadedCoverPath!).delete(); @@ -2462,7 +2457,6 @@ class _TrackMetadataScreenState extends ConsumerState { for (final line in lines) { var cleaned = line.trim(); - // Skip metadata tags if (_lrcMetadataPattern.hasMatch(cleaned) && !_lrcBackgroundLinePattern.hasMatch(cleaned)) { continue; @@ -2474,7 +2468,6 @@ class _TrackMetadataScreenState extends ConsumerState { cleaned = bgMatch.group(1)?.trim() ?? ''; } - // Remove line timestamp, inline word-by-word timestamps, and speaker prefix. cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim(); cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, ''); cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, ''); @@ -2540,7 +2533,7 @@ class _TrackMetadataScreenState extends ConsumerState { WidgetRef ref, ColorScheme colorScheme, ) { - showModalBottomSheet( + showModalBottomSheet( context: screenContext, useRootNavigator: true, shape: const RoundedRectangleBorder( @@ -2685,11 +2678,9 @@ class _TrackMetadataScreenState extends ConsumerState { /// Whether the current file is a CUE sheet (or CUE-referenced) bool get _isCueFile { - // Check if the raw path has a CUE virtual path suffix if (isCueVirtualPath(rawFilePath)) return true; final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.cue')) return true; - // Check if local library item has cue+ format if (_isLocalItem && _localLibraryItem != null) { final format = _localLibraryItem!.format ?? ''; if (format.startsWith('cue+')) return true; @@ -2815,7 +2806,6 @@ class _TrackMetadataScreenState extends ConsumerState { final currentFormat = _currentFileFormat; final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; - // Build available target formats based on source final formats = []; if (currentFormat == 'FLAC') { formats.addAll(['ALAC', 'MP3', 'Opus']); @@ -2834,7 +2824,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool isLosslessTarget = selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( @@ -2906,7 +2896,6 @@ class _TrackMetadataScreenState extends ConsumerState { }).toList(), ), - // Only show bitrate for lossy targets if (!isLosslessTarget) ...[ const SizedBox(height: 16), Text( @@ -2933,7 +2922,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ], - // Show lossless indicator if (isLosslessTarget && isLosslessSource) ...[ const SizedBox(height: 16), Row( @@ -2991,14 +2979,12 @@ class _TrackMetadataScreenState extends ConsumerState { } void _showCueSplitSheet(BuildContext context) async { - // Strip the #trackNN suffix from virtual CUE paths to get the real .cue path var cuePath = cleanFilePath; final trackSuffix = RegExp(r'#track\d+$'); if (trackSuffix.hasMatch(cuePath)) { cuePath = cuePath.replaceFirst(trackSuffix, ''); } - // Show loading indicator ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)), ); @@ -3037,7 +3023,7 @@ class _TrackMetadataScreenState extends ConsumerState { if (!mounted) return; - showModalBottomSheet( + showModalBottomSheet( context: this.context, useRootNavigator: true, isScrollControlled: true, @@ -3093,7 +3079,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - // Track list preview (scrollable, max 200px) ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), child: ListView.builder( @@ -3201,7 +3186,7 @@ class _TrackMetadataScreenState extends ConsumerState { required String date, required List tracks, }) { - showDialog( + showDialog( context: context, builder: (dialogContext) { return AlertDialog( @@ -3315,7 +3300,6 @@ class _TrackMetadataScreenState extends ConsumerState { workingAudioPath = tempPath; } - // Determine output directory final String outputDir; final treeUri = !_isLocalItem ? (_downloadItem?.downloadTreeUri ?? '') @@ -3342,7 +3326,6 @@ class _TrackMetadataScreenState extends ConsumerState { if (!mounted) return; _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); - // Extract cover from audio file for embedding String? coverPath; try { final tempDir = await getTemporaryDirectory(); @@ -3385,11 +3368,9 @@ class _TrackMetadataScreenState extends ConsumerState { for (final path in finalOutputPaths) { if (path.toLowerCase().endsWith('.flac')) { try { - // Read existing metadata first final metadata = await PlatformBridge.readFileMetadata(path); if (metadata['error'] == null) { final fields = {'cover_path': coverPath}; - // Preserve existing fields for (final entry in metadata.entries) { if (entry.key == 'error' || entry.value == null) continue; final v = entry.value.toString().trim(); @@ -3415,7 +3396,6 @@ class _TrackMetadataScreenState extends ConsumerState { finalOutputPaths = exportedUris; } - // Cleanup cover temp if (coverPath != null) { try { await File(coverPath).delete(); @@ -3437,7 +3417,6 @@ class _TrackMetadataScreenState extends ConsumerState { _showSnackBarMessage(_l10nCueSplitFailed); } } finally { - // Cleanup SAF temp audio copy if (safTempAudioPath != null) { try { await File(safTempAudioPath).delete(); @@ -3463,7 +3442,7 @@ class _TrackMetadataScreenState extends ConsumerState { final isLossless = targetFormat.toUpperCase() == 'ALAC' || targetFormat.toUpperCase() == 'FLAC'; - showDialog( + showDialog( context: context, builder: (dialogContext) { return AlertDialog( @@ -3556,7 +3535,6 @@ class _TrackMetadataScreenState extends ConsumerState { String? safTempPath; if (isSaf) { - // Copy SAF file to temp for processing safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); if (safTempPath == null) { if (mounted) { @@ -3576,10 +3554,9 @@ class _TrackMetadataScreenState extends ConsumerState { bitrate: bitrate, metadata: metadata, coverPath: coverPath, - deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it + deleteOriginal: !isSaf, ); - // Cleanup cover temp if (coverPath != null) { try { await File(coverPath).delete(); @@ -3587,7 +3564,6 @@ class _TrackMetadataScreenState extends ConsumerState { } if (newPath == null) { - // Cleanup SAF temp if needed if (safTempPath != null) { try { await File(safTempPath).delete(); @@ -3649,7 +3625,7 @@ class _TrackMetadataScreenState extends ConsumerState { newExt = '.flac'; mimeType = 'audio/flac'; break; - default: // mp3 + default: newExt = '.mp3'; mimeType = 'audio/mpeg'; break; @@ -3689,7 +3665,6 @@ class _TrackMetadataScreenState extends ConsumerState { _log.w('Converted SAF file created but failed deleting original URI'); } - // Update history with new SAF info if (!_isLocalItem) { await HistoryDatabase.instance.updateFilePath( _downloadItem!.id, @@ -3701,7 +3676,6 @@ class _TrackMetadataScreenState extends ConsumerState { await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } - // Cleanup temp files try { await File(newPath).delete(); } catch (_) {} @@ -3711,7 +3685,6 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) {} } } else { - // Regular file: update history with new path if (!_isLocalItem) { await HistoryDatabase.instance.updateFilePath( _downloadItem!.id, @@ -3730,7 +3703,6 @@ class _TrackMetadataScreenState extends ConsumerState { content: Text(context.l10n.trackConvertSuccess(targetFormat)), ), ); - // Pop and let the caller refresh Navigator.pop(context, true); } } catch (e) { @@ -3748,7 +3720,6 @@ class _TrackMetadataScreenState extends ConsumerState { WidgetRef ref, ColorScheme colorScheme, ) async { - // Read current metadata from file, fall back to item data on failure Map? fileMetadata; try { final result = await PlatformBridge.readFileMetadata(cleanFilePath); @@ -3759,7 +3730,6 @@ class _TrackMetadataScreenState extends ConsumerState { debugPrint('readFileMetadata failed, using item data: $e'); } - // Build initial values map — prefer file metadata, fall back to item data String val(String key, String? fallback) { final v = fileMetadata?[key]?.toString(); return (v != null && v.isNotEmpty) ? v : (fallback ?? ''); @@ -3805,7 +3775,6 @@ class _TrackMetadataScreenState extends ConsumerState { ScaffoldMessenger.of(this.context).showSnackBar( SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)), ); - // Re-read metadata from file to refresh the display try { final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath); setState(() => _editedMetadata = refreshed); @@ -3823,7 +3792,7 @@ class _TrackMetadataScreenState extends ConsumerState { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showDialog( context: screenContext, useRootNavigator: true, builder: (dialogContext) => AlertDialog( @@ -4050,10 +4019,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _currentCoverTempDir; bool _loadingCurrentCover = false; - // Auto-fill field selection — which fields the user wants to fetch final Set _autoFillFields = {}; - // All auto-fillable fields and their mapping static const _fieldDefs = { 'title': 'title', 'artist': 'artist', @@ -4679,7 +4646,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { throw StateError('No metadata match resolved for auto-fill'); } - // Extract basic metadata from search result final enriched = { 'title': (selectedBest['name'] ?? '').toString(), 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') @@ -4757,7 +4723,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; - // Fetch genre/label/copyright from Deezer extended metadata if (needsExtended && deezerId != null) { try { final extended = await PlatformBridge.getDeezerExtendedMetadata( @@ -4775,10 +4740,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; - // Apply selected fields to controllers var filledCount = 0; for (final key in _autoFillFields) { - if (key == 'cover') continue; // Handle cover separately below + if (key == 'cover') continue; final value = enriched[key]; if (value != null && value.isNotEmpty && @@ -4792,7 +4756,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } - // Handle cover art download if (_autoFillFields.contains('cover')) { final coverUrl = (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') @@ -5071,7 +5034,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return; } - // For SAF files, copy the processed temp file back if (tempPath != null && safUri != null) { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); if (!ok && mounted) { @@ -5184,7 +5146,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), _field('Genre', _genreCtrl), _field('ISRC', _isrcCtrl), - // Advanced fields toggle Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: InkWell( @@ -5282,7 +5243,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 8), - // Quick select buttons Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( @@ -5302,7 +5262,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 8), - // Field chips Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Wrap( @@ -5339,7 +5298,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 10), - // Fetch button Padding( padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), child: SizedBox( diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index a944f6a8..a8d35705 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -527,7 +527,7 @@ class _InteractiveDownloadExampleState for (int i = 0; i <= 100; i += 5) { if (!mounted) return; - await Future.delayed(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: 50)); setState(() => _progress = i / 100); } @@ -536,7 +536,7 @@ class _InteractiveDownloadExampleState _isCompleted = true; }); - await Future.delayed(const Duration(seconds: 2)); + await Future.delayed(const Duration(seconds: 2)); if (mounted) { setState(() { _isCompleted = false; diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart index 92ca49f8..471c251c 100644 --- a/lib/services/app_state_database.dart +++ b/lib/services/app_state_database.dart @@ -119,7 +119,7 @@ class AppStateDatabase { final db = await database; await db.transaction((txn) async { final batch = txn.batch(); - for (final entry in decoded.whereType()) { + for (final entry in decoded.whereType>()) { final map = Map.from(entry); final id = map['id'] as String?; if (id == null || id.isEmpty) continue; @@ -179,7 +179,7 @@ class AppStateDatabase { final decoded = jsonDecode(rawRecent); if (decoded is List) { final batch = txn.batch(); - for (final entry in decoded.whereType()) { + for (final entry in decoded.whereType>()) { final map = Map.from(entry); final type = map['type'] as String?; final id = map['id'] as String?; diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 6cb77e0d..45d59d18 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -124,7 +124,7 @@ class CsvImportService { ); if (i < tracks.length - 1) { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); } continue; } @@ -198,8 +198,8 @@ class CsvImportService { artistName: artistName ?? 'Unknown Artist', albumName: albumName ?? 'Unknown Album', isrc: isrc, - duration: 0, // Will be updated by enrichment later - coverUrl: null, // Will be fetched by enrichment + duration: 0, + coverUrl: null, ), ); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 53efe84d..801ff147 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1437,7 +1437,6 @@ class FFmpegService { final cmdBuffer = StringBuffer(); cmdBuffer.write('-i "$inputPath" '); - // Cover art as second input for M4A attached picture final hasCover = coverPath != null && coverPath.trim().isNotEmpty && @@ -1455,7 +1454,6 @@ class FFmpegService { cmdBuffer.write('-c:a alac '); cmdBuffer.write('-map_metadata -1 '); - // Embed M4A metadata tags final m4aTags = _convertToM4aTags(metadata); for (final entry in m4aTags.entries) { final sanitized = entry.value.replaceAll('"', '\\"'); @@ -1764,7 +1762,6 @@ class FFmpegService { final outputPaths = []; final inputExt = audioPath.toLowerCase().split('.').last; - // For lossless formats, keep as FLAC; for others, keep original format final outputExt = (inputExt == 'flac' || inputExt == 'wav' || @@ -1836,14 +1833,10 @@ class FFmpegService { final result = await _execute(command); if (!result.success) { _log.e('CUE split failed for track ${track.number}: ${result.output}'); - // Continue with remaining tracks instead of failing completely continue; } - // Embed cover art if available (for FLAC output) if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') { - // Use the Go backend for FLAC cover embedding via PlatformBridge - // (handled by the caller) } outputPaths.add(outputPath); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 9e648bc7..4c851305 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -224,7 +224,7 @@ class HistoryDatabase { } try { - final List jsonList = jsonDecode(jsonStr); + final jsonList = List.from(jsonDecode(jsonStr) as List); _log.i( 'Migrating ${jsonList.length} items from SharedPreferences to SQLite', ); @@ -233,7 +233,7 @@ class HistoryDatabase { final batch = db.batch(); for (final json in jsonList) { - final map = json as Map; + final map = Map.from(json as Map); batch.insert( 'history', _jsonToDbRow(map), @@ -328,6 +328,20 @@ class HistoryDatabase { ); } + Future upsertBatch(List> 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) Future>> getAll({int? limit, int? offset}) async { final db = await database; @@ -532,6 +546,29 @@ class HistoryDatabase { return rows.map((r) => Map.from(r)).toList(); } + Future>> 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.from(r)).toList(); + } + /// Delete multiple entries by IDs Future deleteByIds(List ids) async { if (ids.isEmpty) return 0; diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index 65577cce..db813114 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -155,11 +155,11 @@ class LibraryCollectionsDatabase { final db = await database; await db.transaction((txn) async { - for (final entry in wishlistRaw.whereType()) { + for (final entry in wishlistRaw.whereType>()) { final map = Map.from(entry); final trackKey = map['key'] as String?; final track = map['track']; - if (trackKey == null || track is! Map) continue; + if (trackKey == null || track is! Map) continue; final addedAt = (map['addedAt'] as String?) ?? nowIso; await txn.insert(_tableWishlist, { 'track_key': trackKey, @@ -168,11 +168,11 @@ class LibraryCollectionsDatabase { }, conflictAlgorithm: ConflictAlgorithm.replace); } - for (final entry in lovedRaw.whereType()) { + for (final entry in lovedRaw.whereType>()) { final map = Map.from(entry); final trackKey = map['key'] as String?; final track = map['track']; - if (trackKey == null || track is! Map) continue; + if (trackKey == null || track is! Map) continue; final addedAt = (map['addedAt'] as String?) ?? nowIso; await txn.insert(_tableLoved, { 'track_key': trackKey, @@ -181,7 +181,8 @@ class LibraryCollectionsDatabase { }, conflictAlgorithm: ConflictAlgorithm.replace); } - for (final playlistEntry in playlistsRaw.whereType()) { + for (final playlistEntry + in playlistsRaw.whereType>()) { final playlist = Map.from(playlistEntry); final playlistId = playlist['id'] as String?; if (playlistId == null || playlistId.isEmpty) continue; @@ -197,11 +198,12 @@ class LibraryCollectionsDatabase { }, conflictAlgorithm: ConflictAlgorithm.replace); final tracksRaw = (playlist['tracks'] as List?) ?? const []; - for (final trackEntry in tracksRaw.whereType()) { + for (final trackEntry + in tracksRaw.whereType>()) { final trackMap = Map.from(trackEntry); final trackKey = trackMap['key'] as String?; final track = trackMap['track']; - if (trackKey == null || track is! Map) continue; + if (trackKey == null || track is! Map) continue; final addedAt = (trackMap['addedAt'] as String?) ?? nowIso; await txn.insert(_tablePlaylistTracks, { 'playlist_id': playlistId, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 47b3ab19..a7eb9701 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -255,20 +255,41 @@ class LibraryDatabase { Future upsertBatch(List> items) async { if (items.isEmpty) return; final db = await database; - final batch = db.batch(); - - for (final json in items) { - batch.insert( - 'library', - _jsonToDbRow(json), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - - await batch.commit(noResult: true); + await db.transaction((txn) async { + final batch = txn.batch(); + for (final json in items) { + batch.insert( + 'library', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); _log.i('Batch inserted ${items.length} items'); } + Future replaceAll(List> 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>> getAll({int? limit, int? offset}) async { final db = await database; final rows = await db.query( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index e2c1df83..0dfa2bcf 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -67,8 +67,8 @@ class PlatformBridge { if (response['success'] == true) { final service = response['service'] ?? payload.service; final filePath = response['file_path'] ?? ''; - final bitDepth = response['actual_bit_depth']; - final sampleRate = response['actual_sample_rate']; + final bitDepth = response['actual_bit_depth'] as num?; + final sampleRate = response['actual_sample_rate'] as num?; final qualityStr = bitDepth != null && sampleRate != null ? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)' : ''; @@ -83,24 +83,18 @@ class PlatformBridge { static Future> getDownloadProgress() async { final result = await _channel.invokeMethod('getDownloadProgress'); - return jsonDecode(result as String) as Map; + return _decodeMapResult(result); } static Future> getAllDownloadProgress() async { final result = await _channel.invokeMethod('getAllDownloadProgress'); - return jsonDecode(result as String) as Map; + return _decodeMapResult(result); } static Stream> downloadProgressStream() { - return _downloadProgressEvents.receiveBroadcastStream().map((event) { - if (event is String) { - return jsonDecode(event) as Map; - } - if (event is Map) { - return Map.from(event); - } - return const {}; - }); + return _downloadProgressEvents.receiveBroadcastStream().map( + _decodeMapResult, + ); } static Future exitApp() async { @@ -1087,7 +1081,6 @@ class PlatformBridge { } } - /// Set the directory for caching extracted cover art static Future setLibraryCoverCacheDir(String cacheDir) async { _log.i('setLibraryCoverCacheDir: $cacheDir'); 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>> scanLibraryFolder( String folderPath, ) async { @@ -1108,10 +1099,6 @@ class PlatformBridge { return list.map((e) => e as Map).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> scanLibraryFolderIncremental( String folderPath, Map existingFiles, @@ -1146,8 +1133,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Incremental SAF tree scan - only scans new or modified files - /// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files) static Future> scanSafTreeIncremental( String treeUri, Map existingFiles, @@ -1173,8 +1158,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// 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> getSafFileModTimes(List uris) async { final result = await _channel.invokeMethod('getSafFileModTimes', { 'uris': jsonEncode(uris), @@ -1183,29 +1166,35 @@ class PlatformBridge { return map.map((key, value) => MapEntry(key, (value as num).toInt())); } - /// Get current library scan progress static Future> getLibraryScanProgress() async { final result = await _channel.invokeMethod('getLibraryScanProgress'); - return jsonDecode(result as String) as Map; + return _decodeMapResult(result); } static Stream> libraryScanProgressStream() { - return _libraryScanProgressEvents.receiveBroadcastStream().map((event) { - if (event is String) { - return jsonDecode(event) as Map; - } - if (event is Map) { - return Map.from(event); - } - return const {}; - }); + return _libraryScanProgressEvents.receiveBroadcastStream().map( + _decodeMapResult, + ); } - /// Cancel ongoing library scan static Future cancelLibraryScan() async { await _channel.invokeMethod('cancelLibraryScan'); } + static Map _decodeMapResult(dynamic result) { + if (result is Map) { + return Map.from(result); + } + if (result is String) { + if (result.isEmpty) return const {}; + final decoded = jsonDecode(result); + if (decoded is Map) { + return Map.from(decoded); + } + } + return const {}; + } + // MARK: - iOS Security-Scoped Bookmark /// 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?> readAudioMetadata( String filePath, ) async { @@ -1367,10 +1355,6 @@ class PlatformBridge { 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> parseCueSheet( String cuePath, { String audioDir = '', diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index e958394f..c5f174d0 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -65,7 +65,7 @@ class ShareIntentService { _mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen( _handleSharedMedia, - onError: (err) => _log.e('Error: $err'), + onError: (Object err) => _log.e('Error: $err'), ); final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia(); @@ -80,7 +80,6 @@ class ShareIntentService { bool isInitial = false, }) { 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!]; for (final textToCheck in textsToCheck) { @@ -100,13 +99,11 @@ class ShareIntentService { String? _extractMusicUrl(String text) { if (text.isEmpty) return null; - // Try Spotify URI first final uriMatch = _spotifyUriPattern.firstMatch(text); if (uriMatch != null) { return uriMatch.group(0); } - // Try all URL patterns final patterns = [ _spotifyUrlPattern, _deezerUrlPattern, diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index c24b47f0..7ea63307 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -1,11 +1,26 @@ import 'dart:convert'; import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('UpdateChecker'); +enum _ApkVariant { arm64, arm32, universal } + +class _ApkAsset { + final String name; + final String url; + final _ApkVariant variant; + + const _ApkAsset({ + required this.name, + required this.url, + required this.variant, + }); +} + class UpdateInfo { final String version; final String changelog; @@ -94,32 +109,15 @@ class UpdateChecker { DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); - String? arm64Url; - String? universalUrl; - - final assets = releaseData['assets'] as List? ?? []; - for (final asset in assets) { - final name = (asset['name'] as String? ?? '').toLowerCase(); - if (name.endsWith('.apk')) { - final downloadUrl = asset['browser_download_url'] as String?; - final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null; - if (uri == null || uri.scheme != 'https') { - _log.w('Skipping non-HTTPS APK URL: $downloadUrl'); - continue; - } - if (name.contains('arm64') || name.contains('v8a')) { - arm64Url = downloadUrl; - } else if (name.contains('universal')) { - universalUrl = downloadUrl; - } - } - } - - // Only arm64 is supported; fall back to universal if available - final apkUrl = arm64Url ?? universalUrl; + final assets = _collectApkAssets( + releaseData['assets'] as List? ?? const [], + ); + final selectedAsset = await _selectApkForCurrentDevice(assets); + final apkUrl = selectedAsset?.url; _log.i( - 'Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl', + 'Update available: $latestVersion (prerelease: $isPrerelease), ' + 'APK asset: ${selectedAsset?.name ?? 'none'}, APK URL: $apkUrl', ); return UpdateInfo( @@ -169,4 +167,128 @@ class UpdateChecker { } static String get currentVersion => AppInfo.version; + + static List<_ApkAsset> _collectApkAssets(List assets) { + final apkAssets = <_ApkAsset>[]; + + for (final asset in assets.whereType>()) { + final assetMap = Map.from(asset); + final name = (assetMap['name'] as String? ?? '').trim(); + final normalizedName = name.toLowerCase(); + if (!normalizedName.endsWith('.apk')) { + continue; + } + + final downloadUrl = assetMap['browser_download_url'] as String?; + final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null; + if (uri == null || uri.scheme != 'https') { + _log.w('Skipping non-HTTPS APK URL: $downloadUrl'); + continue; + } + + final variant = _apkVariantFromName(normalizedName); + if (variant == null) { + _log.w('Skipping APK with unknown variant: $name'); + continue; + } + + apkAssets.add( + _ApkAsset(name: name, url: uri.toString(), variant: variant), + ); + } + + return apkAssets; + } + + static _ApkVariant? _apkVariantFromName(String name) { + if (name.contains('universal')) { + return _ApkVariant.universal; + } + if (name.contains('arm64') || name.contains('arm64-v8a')) { + return _ApkVariant.arm64; + } + if (name.contains('arm32') || + name.contains('armeabi') || + name.contains('armv7') || + name.contains('v7a')) { + return _ApkVariant.arm32; + } + return null; + } + + static Future<_ApkAsset?> _selectApkForCurrentDevice( + List<_ApkAsset> assets, + ) async { + if (assets.isEmpty) { + return null; + } + + _ApkAsset? arm64Asset; + _ApkAsset? arm32Asset; + _ApkAsset? universalAsset; + for (final asset in assets) { + switch (asset.variant) { + case _ApkVariant.arm64: + arm64Asset ??= asset; + break; + case _ApkVariant.arm32: + arm32Asset ??= asset; + break; + case _ApkVariant.universal: + universalAsset ??= asset; + break; + } + } + + final supportedAbis = await _getSupportedAndroidAbis(); + final hasArm64 = supportedAbis.any(_isArm64Abi); + final hasArm32 = supportedAbis.any(_isArm32Abi); + + if (hasArm64) { + return arm64Asset ?? universalAsset ?? arm32Asset; + } + if (hasArm32) { + return arm32Asset ?? universalAsset; + } + + if (universalAsset != null) { + _log.w( + 'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; ' + 'falling back to universal APK.', + ); + return universalAsset; + } + + _log.w( + 'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; ' + 'no universal APK available.', + ); + return null; + } + + static Future> _getSupportedAndroidAbis() async { + if (!Platform.isAndroid) { + return const []; + } + + try { + final androidInfo = await DeviceInfoPlugin().androidInfo; + final supportedAbis = androidInfo.supportedAbis + .map((abi) => abi.toLowerCase()) + .where((abi) => abi.isNotEmpty) + .toSet() + .toList(); + _log.i('Detected supported Android ABIs: ${supportedAbis.join(', ')}'); + return supportedAbis; + } catch (e) { + _log.w('Failed to detect supported Android ABIs: $e'); + return const []; + } + } + + static bool _isArm64Abi(String abi) => + abi.contains('arm64') || abi.contains('aarch64'); + + static bool _isArm32Abi(String abi) => + abi.contains('armeabi') || abi.contains('armv7') || abi.contains('arm'); } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index c13b54ee..309a4c9a 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -89,9 +89,7 @@ class AppTheme { static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData( elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), color: scheme.surfaceContainerLow, surfaceTintColor: scheme.surfaceTint, ); @@ -148,9 +146,7 @@ class AppTheme { static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) => InputDecorationTheme( filled: true, - fillColor: scheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), + fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.3), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, @@ -175,9 +171,7 @@ class AppTheme { static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); @@ -237,7 +231,7 @@ class AppTheme { ); static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), backgroundColor: scheme.surfaceContainerLow, selectedColor: scheme.secondaryContainer, ); diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index e7f6d8d7..b39f1bd1 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -27,7 +27,6 @@ Future navigateToArtist( final normalizedArtistId = _normalizeArtistId(artistId); - // If we have a valid artist ID already, navigate directly if (normalizedArtistId != null && _canNavigateArtistDirectly( artistId: normalizedArtistId, @@ -43,7 +42,6 @@ Future navigateToArtist( return; } - // Search Deezer to resolve the artist ID _showLoadingSnackBar(context, 'Looking up artist...'); try { final results = await PlatformBridge.searchDeezerAll( @@ -60,7 +58,6 @@ Future navigateToArtist( return; } - // Find best match - prefer exact name match (case-insensitive) Map? bestMatch; final lowerName = artistName.toLowerCase().trim(); for (final a in artistList) { @@ -113,7 +110,6 @@ Future navigateToAlbum( }) async { if (albumName.isEmpty) return; - // If we have a valid album ID already, navigate directly if (albumId != null && albumId.isNotEmpty && albumId != 'unknown' && @@ -128,16 +124,13 @@ Future navigateToAlbum( return; } - // If it's extension-based content without an ID, can't search Deezer for it if (extensionId != null) { _showUnavailable(context, 'Album'); return; } - // Search Deezer to resolve the album ID _showLoadingSnackBar(context, 'Looking up album...'); try { - // Build search query: "albumName artistName" for better accuracy final query = artistName != null && artistName.isNotEmpty ? '$albumName $artistName' : albumName; @@ -156,7 +149,6 @@ Future navigateToAlbum( return; } - // Find best match - prefer exact name match (case-insensitive) Map? bestMatch; final lowerName = albumName.toLowerCase().trim(); for (final a in albumList) { @@ -225,11 +217,19 @@ void _pushAlbumScreen( String? coverUrl, 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( context, - (context) => extensionId != null + (context) => isExtension && resolvedExtensionId != null ? ExtensionAlbumScreen( - extensionId: extensionId, + extensionId: resolvedExtensionId, albumId: albumId, albumName: albumName, coverUrl: coverUrl, @@ -252,7 +252,7 @@ void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { identical(currentNavigator, rootNavigator) && activeTabNavigator != null; if (!shouldRouteToTabNavigator) { - currentNavigator.push(MaterialPageRoute(builder: builder)); + currentNavigator.push(MaterialPageRoute(builder: builder)); return; } @@ -264,12 +264,12 @@ void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { currentNavigator.pop(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!activeTabNavigator.mounted) return; - activeTabNavigator.push(MaterialPageRoute(builder: builder)); + activeTabNavigator.push(MaterialPageRoute(builder: builder)); }); return; } - activeTabNavigator.push(MaterialPageRoute(builder: builder)); + activeTabNavigator.push(MaterialPageRoute(builder: builder)); } void _showLoadingSnackBar(BuildContext context, String message) { diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 3a32375c..2601c70c 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -179,15 +179,16 @@ class LogBuffer extends ChangeNotifier { final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; final keepNonErrorLogs = _loggingEnabled; - for (final log in logs) { - final level = log['level'] as String? ?? 'INFO'; + for (final log in logs.whereType>()) { + final logMap = Map.from(log); + final level = logMap['level'] as String? ?? 'INFO'; if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') { continue; } - final timestamp = log['timestamp'] as String? ?? ''; - final tag = log['tag'] as String? ?? 'Go'; - final message = log['message'] as String? ?? ''; + final timestamp = logMap['timestamp'] as String? ?? ''; + final tag = logMap['tag'] as String? ?? 'Go'; + final message = logMap['message'] as String? ?? ''; DateTime parsedTime = DateTime.now(); if (timestamp.isNotEmpty) { diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart index 0df1c023..3c6969ec 100644 --- a/lib/utils/path_match_keys.dart +++ b/lib/utils/path_match_keys.dart @@ -22,6 +22,9 @@ const _audioExtensions = [ '.aac', ]; +const _maxPathMatchKeyCacheSize = 6000; +final Map> _pathMatchKeyCache = >{}; + /// Strips a trailing audio extension from [path] if present. /// Returns the path without extension, or `null` if no known audio extension /// was found. @@ -41,6 +44,11 @@ Set buildPathMatchKeys(String? filePath) { final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw; if (cleaned.isEmpty) return const {}; + final cached = _pathMatchKeyCache.remove(cleaned); + if (cached != null) { + _pathMatchKeyCache[cleaned] = cached; + return cached; + } final keys = {}; final visited = {}; @@ -118,7 +126,12 @@ Set buildPathMatchKeys(String? filePath) { } keys.addAll(extensionStrippedKeys); - return keys; + final result = Set.unmodifiable(keys); + _pathMatchKeyCache[cleaned] = result; + while (_pathMatchKeyCache.length > _maxPathMatchKeyCacheSize) { + _pathMatchKeyCache.remove(_pathMatchKeyCache.keys.first); + } + return result; } Iterable _androidEquivalentPaths(String path) { diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart new file mode 100644 index 00000000..14c3d978 --- /dev/null +++ b/lib/widgets/animation_utils.dart @@ -0,0 +1,894 @@ +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( + 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 slidePageRoute({required Widget page}) { + return MaterialPageRoute(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 createState() => _ShimmerLoadingState(); +} + +class _ShimmerLoadingState extends State + 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.75, + ), + itemCount: itemCount, + itemBuilder: (context, index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Expanded( + child: SkeletonBox( + width: double.infinity, + height: double.infinity, + borderRadius: 12, + ), + ), + 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; + final bool showCoverHeader; + final bool showPopularSection; + + const ArtistScreenSkeleton({ + super.key, + this.popularCount = 5, + this.albumCount = 5, + this.showCoverHeader = true, + this.showPopularSection = true, + }); + + @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: [ + if (showCoverHeader) ...[ + 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), + ), + if (showPopularSection) ...[ + 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 createState() => _DownloadSuccessOverlayState(); +} + +class _DownloadSuccessOverlayState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _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([ + 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 createState() => _AnimatedBadgeState(); +} + +class _AnimatedBadgeState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + int _previousCount = 0; + + @override + void initState() { + super.initState(); + _previousCount = widget.count; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _scaleAnimation = TweenSequence([ + 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 animation) { + return SizeTransition( + sizeFactor: CurvedAnimation(parent: animation, curve: Curves.easeInOut), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn), + child: child, + ), + ); +} diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart new file mode 100644 index 00000000..d88c55b0 --- /dev/null +++ b/lib/widgets/audio_analysis_widget.dart @@ -0,0 +1,1155 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/level.dart'; +import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +class AudioAnalysisData { + final String filePath; + final int fileSize; + final int sampleRate; + final int channels; + final int bitsPerSample; + final double duration; + final int bitrate; + final String bitDepth; + final double dynamicRange; + final double peakAmplitude; + final double rmsLevel; + final int totalSamples; + final SpectrogramData? spectrum; + + const AudioAnalysisData({ + required this.filePath, + required this.fileSize, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.duration, + required this.bitrate, + required this.bitDepth, + required this.dynamicRange, + required this.peakAmplitude, + required this.rmsLevel, + required this.totalSamples, + this.spectrum, + }); + + Map toJson() => { + 'filePath': filePath, + 'fileSize': fileSize, + 'sampleRate': sampleRate, + 'channels': channels, + 'bitsPerSample': bitsPerSample, + 'duration': duration, + 'bitrate': bitrate, + 'bitDepth': bitDepth, + 'dynamicRange': dynamicRange, + 'peakAmplitude': peakAmplitude, + 'rmsLevel': rmsLevel, + 'totalSamples': totalSamples, + }; + + factory AudioAnalysisData.fromJson(Map json) { + return AudioAnalysisData( + filePath: json['filePath'] as String, + fileSize: json['fileSize'] as int, + sampleRate: json['sampleRate'] as int, + channels: json['channels'] as int, + bitsPerSample: json['bitsPerSample'] as int, + duration: (json['duration'] as num).toDouble(), + bitrate: json['bitrate'] as int, + bitDepth: json['bitDepth'] as String, + dynamicRange: (json['dynamicRange'] as num).toDouble(), + peakAmplitude: (json['peakAmplitude'] as num).toDouble(), + rmsLevel: (json['rmsLevel'] as num).toDouble(), + totalSamples: json['totalSamples'] as int, + ); + } +} + +class SpectrogramData { + final List magnitudes; + final int sampleRate; + final int freqBins; + final double duration; + final double maxFreq; + final int sliceCount; + + const SpectrogramData({ + required this.magnitudes, + required this.sampleRate, + required this.freqBins, + required this.duration, + required this.maxFreq, + required this.sliceCount, + }); +} + +class AudioAnalysisCard extends StatefulWidget { + final String filePath; + + const AudioAnalysisCard({super.key, required this.filePath}); + + @override + State createState() => _AudioAnalysisCardState(); +} + +class _AudioAnalysisCardState extends State { + AudioAnalysisData? _data; + bool _analyzing = false; + bool _checkingCache = true; + String? _error; + ui.Image? _spectrogramImage; + + static const _supportedExtensions = { + '.flac', + '.mp3', + '.m4a', + '.aac', + '.opus', + '.ogg', + '.wav', + '.wma', + }; + + bool get _isSupported { + final lower = widget.filePath.toLowerCase(); + return _supportedExtensions.any((ext) => lower.endsWith(ext)); + } + + @override + void initState() { + super.initState(); + if (_isSupported) { + _tryLoadFromCache(); + } + } + + @override + void dispose() { + _spectrogramImage?.dispose(); + super.dispose(); + } + + Future _tryLoadFromCache() async { + try { + final cached = await _loadFromCache(widget.filePath); + if (cached != null && mounted) { + setState(() { + _data = cached; + _checkingCache = false; + }); + final image = await _loadSpectrogramFromCache(widget.filePath); + if (image != null && mounted) { + setState(() { + _spectrogramImage?.dispose(); + _spectrogramImage = image; + }); + } + return; + } + } catch (_) {} + if (mounted) { + setState(() => _checkingCache = false); + } + } + + Future _analyze() async { + if (_analyzing) return; + setState(() { + _analyzing = true; + _error = null; + }); + + try { + final cached = await _loadFromCache(widget.filePath); + AudioAnalysisData data; + bool fromCache = false; + + if (cached != null) { + data = cached; + fromCache = true; + } else { + data = await _runAnalysis(widget.filePath); + _saveToCache(widget.filePath, data); + } + + ui.Image? image; + if (fromCache) { + image = await _loadSpectrogramFromCache(widget.filePath); + } + if (image == null && + data.spectrum != null && + data.spectrum!.sliceCount > 0) { + image = await _renderSpectrogramToImage(data.spectrum!); + _saveSpectrogramToCache(widget.filePath, image); + } + + if (mounted) { + setState(() { + _data = data; + _spectrogramImage?.dispose(); + _spectrogramImage = image; + _analyzing = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _analyzing = false; + }); + } + } + } + + static String _cacheKey(String filePath) { + var hash = 0xcbf29ce484222325; + for (final byte in utf8.encode(filePath)) { + hash ^= byte; + hash = (hash * 0x100000001b3) & 0x7FFFFFFFFFFFFFFF; + } + return hash.toRadixString(16); + } + + static Future _cacheDir() async { + final appSupport = await getApplicationSupportDirectory(); + final dir = Directory('${appSupport.path}/audio_analysis_cache'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static Future _loadFromCache(String filePath) async { + try { + final dir = await _cacheDir(); + final key = _cacheKey(filePath); + final file = File('${dir.path}/$key.json'); + if (!await file.exists()) return null; + + final json = Map.from( + jsonDecode(await file.readAsString()) as Map, + ); + final cachedSize = json['fileSize'] as int; + + if (!filePath.startsWith('content://')) { + final currentSize = await File(filePath).length(); + if (currentSize != cachedSize) return null; + } + + return AudioAnalysisData.fromJson(json); + } catch (_) { + return null; + } + } + + static Future _saveToCache( + String filePath, + AudioAnalysisData data, + ) async { + try { + final dir = await _cacheDir(); + final key = _cacheKey(filePath); + final file = File('${dir.path}/$key.json'); + await file.writeAsString(jsonEncode(data.toJson())); + } catch (_) {} + } + + static Future _saveSpectrogramToCache( + String filePath, + ui.Image image, + ) async { + try { + final dir = await _cacheDir(); + final key = _cacheKey(filePath); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData != null) { + final file = File('${dir.path}/$key.png'); + await file.writeAsBytes(byteData.buffer.asUint8List()); + } + } catch (_) {} + } + + static Future _loadSpectrogramFromCache(String filePath) async { + try { + final dir = await _cacheDir(); + final key = _cacheKey(filePath); + final file = File('${dir.path}/$key.png'); + if (!await file.exists()) return null; + + final bytes = await file.readAsBytes(); + final completer = Completer(); + ui.decodeImageFromList(bytes, completer.complete); + return completer.future; + } catch (_) { + return null; + } + } + + Future _runAnalysis(String filePath) async { + await FFmpegKitConfig.setLogLevel(Level.avLogError); + + String workingPath = filePath; + String? tempCopy; + if (filePath.startsWith('content://')) { + tempCopy = await PlatformBridge.copyContentUriToTemp(filePath); + if (tempCopy == null) { + throw Exception('Failed to copy SAF file for analysis'); + } + workingPath = tempCopy; + } + + try { + final info = await _getMediaInfo(workingPath); + + final tempDir = await getTemporaryDirectory(); + final pcmPath = + '${tempDir.path}/analysis_pcm_${DateTime.now().millisecondsSinceEpoch}.raw'; + + try { + await _decodeToPCM(workingPath, pcmPath, info.sampleRate); + + final pcmBytes = await File(pcmPath).readAsBytes(); + final result = await compute( + _analyzeInIsolate, + _AnalysisParams( + pcmBytes: pcmBytes, + sampleRate: info.sampleRate, + bitsPerSample: info.bitsPerSample, + ), + ); + + final trueTotalSamples = + (info.duration * info.sampleRate * info.channels).round(); + + return AudioAnalysisData( + filePath: filePath, + fileSize: info.fileSize, + sampleRate: info.sampleRate, + channels: info.channels, + bitsPerSample: info.bitsPerSample, + duration: info.duration, + bitrate: info.bitrate, + bitDepth: info.bitsPerSample > 0 + ? '${info.bitsPerSample}-bit' + : 'N/A', + dynamicRange: result.dynamicRange, + peakAmplitude: result.peakAmplitude, + rmsLevel: result.rmsLevel, + totalSamples: trueTotalSamples, + spectrum: result.spectrum, + ); + } finally { + try { + await File(pcmPath).delete(); + } catch (_) {} + } + } finally { + if (tempCopy != null) { + try { + await File(tempCopy).delete(); + } catch (_) {} + } + await FFmpegKitConfig.setLogLevel(Level.avLogInfo); + } + } + + Future<_MediaInfo> _getMediaInfo(String filePath) async { + final session = await FFprobeKit.getMediaInformation(filePath); + final info = session.getMediaInformation(); + + if (info == null) { + throw Exception('Failed to get media information'); + } + + int fileSize = 0; + try { + fileSize = await File(filePath).length(); + } catch (_) {} + + final streams = info.getStreams(); + final audioStream = streams.firstWhere( + (s) => s.getAllProperties()?['codec_type'] == 'audio', + orElse: () => throw Exception('No audio stream found'), + ); + + final props = audioStream.getAllProperties() ?? {}; + final sampleRate = + int.tryParse(props['sample_rate']?.toString() ?? '') ?? 0; + final channels = int.tryParse(props['channels']?.toString() ?? '') ?? 0; + final duration = + double.tryParse( + info.getDuration() ?? props['duration']?.toString() ?? '', + ) ?? + 0; + final bitrate = + int.tryParse( + info.getBitrate() ?? props['bit_rate']?.toString() ?? '', + ) ?? + 0; + + int bitsPerSample = + int.tryParse(props['bits_per_raw_sample']?.toString() ?? '') ?? 0; + if (bitsPerSample == 0) { + bitsPerSample = + int.tryParse(props['bits_per_sample']?.toString() ?? '') ?? 0; + } + + if (bitsPerSample == 0) { + final sampleFmt = props['sample_fmt']?.toString() ?? ''; + if (sampleFmt.contains('16') || + sampleFmt == 's16' || + sampleFmt == 's16p') { + bitsPerSample = 16; + } else if (sampleFmt.contains('32') || + sampleFmt == 'flt' || + sampleFmt == 'fltp') { + bitsPerSample = 32; + } else if (sampleFmt.contains('24') || sampleFmt == 's24') { + bitsPerSample = 24; + } + } + + return _MediaInfo( + fileSize: fileSize, + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample, + duration: duration, + bitrate: bitrate, + ); + } + + Future _decodeToPCM( + String inputPath, + String outputPath, + int sampleRate, + ) async { + final maxDuration = sampleRate > 0 ? (10000000 / sampleRate) : 300; + + final session = await FFmpegKit.executeWithArguments([ + '-loglevel', + 'error', + '-i', + inputPath, + '-t', + maxDuration.toStringAsFixed(1), + '-ac', + '1', + '-ar', + sampleRate.toString(), + '-f', + 's16le', + '-acodec', + 'pcm_s16le', + '-y', + outputPath, + ]); + + final returnCode = await session.getReturnCode(); + if (!ReturnCode.isSuccess(returnCode)) { + final logs = await session.getLogsAsString(); + throw Exception('FFmpeg decode failed: $logs'); + } + } + + Future _renderSpectrogramToImage(SpectrogramData spectrum) async { + const imgWidth = 800; + const imgHeight = 400; + + final pixels = await compute( + _renderSpectrogramPixels, + _SpectrogramRenderParams( + spectrum: spectrum, + width: imgWidth, + height: imgHeight, + ), + ); + + final completer = Completer(); + ui.decodeImageFromPixels( + pixels, + imgWidth, + imgHeight, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } + + @override + Widget build(BuildContext context) { + if (!_isSupported) return const SizedBox.shrink(); + + final cs = Theme.of(context).colorScheme; + final l10n = context.l10n; + + if (_checkingCache) return const SizedBox.shrink(); + + if (_analyzing) { + return Card( + color: cs.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + const SizedBox(height: 12), + Text( + l10n.audioAnalysisAnalyzing, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ), + ); + } + + if (_error != null) { + return Card( + color: cs.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error_outline, color: cs.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + _error!, + style: TextStyle(color: cs.onErrorContainer, fontSize: 13), + ), + ), + ], + ), + ), + ); + } + + if (_data == null) { + return Card( + color: cs.surfaceContainerLow, + child: InkWell( + onTap: _analyze, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon(Icons.analytics_outlined, color: cs.primary, size: 28), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.audioAnalysisTitle, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + const SizedBox(height: 2), + Text( + l10n.audioAnalysisDescription, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], + ), + ), + ), + ); + } + + final data = _data!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AudioInfoCard(data: data), + if (_spectrogramImage != null) ...[ + const SizedBox(height: 12), + _SpectrogramView( + image: _spectrogramImage!, + sampleRate: data.sampleRate, + maxFreq: data.spectrum?.maxFreq ?? data.sampleRate / 2, + ), + ], + ], + ); + } +} + +class _MediaInfo { + final int fileSize; + final int sampleRate; + final int channels; + final int bitsPerSample; + final double duration; + final int bitrate; + + const _MediaInfo({ + required this.fileSize, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.duration, + required this.bitrate, + }); +} + +class _AnalysisParams { + final Uint8List pcmBytes; + final int sampleRate; + final int bitsPerSample; + + const _AnalysisParams({ + required this.pcmBytes, + required this.sampleRate, + required this.bitsPerSample, + }); +} + +class _AnalysisResult { + final double dynamicRange; + final double peakAmplitude; + final double rmsLevel; + final int totalSamples; + final SpectrogramData? spectrum; + + const _AnalysisResult({ + required this.dynamicRange, + required this.peakAmplitude, + required this.rmsLevel, + required this.totalSamples, + this.spectrum, + }); +} + +_AnalysisResult _analyzeInIsolate(_AnalysisParams params) { + final byteData = ByteData.sublistView(params.pcmBytes); + final sampleCount = params.pcmBytes.length ~/ 2; + final samples = Float64List(sampleCount); + + for (int i = 0; i < sampleCount; i++) { + final raw = byteData.getInt16(i * 2, Endian.little); + samples[i] = raw / 32768.0; + } + + double peak = 0; + double sumSquares = 0; + for (int i = 0; i < samples.length; i++) { + final abs = samples[i].abs(); + if (abs > peak) peak = abs; + sumSquares += samples[i] * samples[i]; + } + + final peakDB = peak > 0 ? 20.0 * math.log(peak) / math.ln10 : -100.0; + final rms = math.sqrt(sumSquares / samples.length); + final rmsDB = rms > 0 ? 20.0 * math.log(rms) / math.ln10 : -100.0; + + SpectrogramData? spectrum; + if (samples.length >= 8192) { + spectrum = _computeSpectrum(samples, params.sampleRate); + } + + return _AnalysisResult( + dynamicRange: peakDB - rmsDB, + peakAmplitude: peakDB, + rmsLevel: rmsDB, + totalSamples: sampleCount, + spectrum: spectrum, + ); +} + +SpectrogramData _computeSpectrum(Float64List samples, int sampleRate) { + const fftSize = 8192; + const numSlices = 300; + const freqBins = fftSize ~/ 2; + + final duration = samples.length / sampleRate; + var samplesPerSlice = samples.length ~/ numSlices; + var actualSlices = numSlices; + if (samplesPerSlice < fftSize) { + samplesPerSlice = fftSize; + actualSlices = samples.length ~/ fftSize; + } + + final magnitudes = []; + + for (int i = 0; i < actualSlices; i++) { + final start = i * samplesPerSlice; + if (start + fftSize > samples.length) break; + + final windowed = Float64List(fftSize); + for (int j = 0; j < fftSize; j++) { + final w = 0.5 * (1.0 - math.cos(2.0 * math.pi * j / (fftSize - 1))); + windowed[j] = samples[start + j] * w; + } + + final spectrum = _fft(windowed); + + final mags = Float64List(freqBins); + for (int j = 0; j < freqBins; j++) { + final re = spectrum[j * 2]; + final im = spectrum[j * 2 + 1]; + var mag = math.sqrt(re * re + im * im); + if (mag < 1e-10) mag = 1e-10; + mags[j] = 20.0 * math.log(mag) / math.ln10; + } + magnitudes.add(mags); + } + + return SpectrogramData( + magnitudes: magnitudes, + sampleRate: sampleRate, + freqBins: freqBins, + duration: duration, + maxFreq: sampleRate / 2.0, + sliceCount: magnitudes.length, + ); +} + +/// Cooley-Tukey radix-2 FFT. Returns interleaved [re, im, re, im, ...]. +Float64List _fft(Float64List realInput) { + final n = realInput.length; + final data = Float64List(n * 2); + for (int i = 0; i < n; i++) { + data[i * 2] = realInput[i]; + } + + int j = 0; + for (int i = 0; i < n; i++) { + if (i < j) { + final tr = data[i * 2]; + final ti = data[i * 2 + 1]; + data[i * 2] = data[j * 2]; + data[i * 2 + 1] = data[j * 2 + 1]; + data[j * 2] = tr; + data[j * 2 + 1] = ti; + } + int m = n >> 1; + while (m >= 1 && j >= m) { + j -= m; + m >>= 1; + } + j += m; + } + + for (int size = 2; size <= n; size <<= 1) { + final halfSize = size >> 1; + final angle = -2.0 * math.pi / size; + final wRe = math.cos(angle); + final wIm = math.sin(angle); + + for (int i = 0; i < n; i += size) { + double curRe = 1.0; + double curIm = 0.0; + + for (int k = 0; k < halfSize; k++) { + final evenIdx = (i + k) * 2; + final oddIdx = (i + k + halfSize) * 2; + + final tRe = curRe * data[oddIdx] - curIm * data[oddIdx + 1]; + final tIm = curRe * data[oddIdx + 1] + curIm * data[oddIdx]; + + data[oddIdx] = data[evenIdx] - tRe; + data[oddIdx + 1] = data[evenIdx + 1] - tIm; + data[evenIdx] += tRe; + data[evenIdx + 1] += tIm; + + final newRe = curRe * wRe - curIm * wIm; + curIm = curRe * wIm + curIm * wRe; + curRe = newRe; + } + } + } + + return data; +} + +class _AudioInfoCard extends StatelessWidget { + final AudioAnalysisData data; + + const _AudioInfoCard({required this.data}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final nyquist = data.sampleRate / 2; + + return Card( + color: cs.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.analytics_outlined, color: cs.primary, size: 20), + const SizedBox(width: 8), + Text( + context.l10n.audioAnalysisTitle, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _MetricChip( + icon: Icons.graphic_eq, + label: context.l10n.audioAnalysisSampleRate, + value: '${(data.sampleRate / 1000).toStringAsFixed(1)} kHz', + cs: cs, + ), + _MetricChip( + icon: Icons.audio_file, + label: context.l10n.audioAnalysisBitDepth, + value: data.bitDepth, + cs: cs, + ), + _MetricChip( + icon: Icons.surround_sound, + label: context.l10n.audioAnalysisChannels, + value: data.channels == 2 + ? 'Stereo' + : data.channels == 1 + ? 'Mono' + : '${data.channels}', + cs: cs, + ), + _MetricChip( + icon: Icons.timer_outlined, + label: context.l10n.audioAnalysisDuration, + value: _formatDuration(data.duration), + cs: cs, + ), + _MetricChip( + icon: Icons.speed, + label: context.l10n.audioAnalysisNyquist, + value: '${(nyquist / 1000).toStringAsFixed(1)} kHz', + cs: cs, + ), + if (data.fileSize > 0) + _MetricChip( + icon: Icons.storage, + label: context.l10n.audioAnalysisFileSize, + value: _formatFileSize(data.fileSize), + cs: cs, + ), + ], + ), + const SizedBox(height: 8), + Divider(color: cs.outlineVariant), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _MetricChip( + icon: Icons.trending_up, + label: context.l10n.audioAnalysisDynamicRange, + value: '${data.dynamicRange.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.show_chart, + label: context.l10n.audioAnalysisPeak, + value: '${data.peakAmplitude.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.equalizer, + label: context.l10n.audioAnalysisRms, + value: '${data.rmsLevel.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.numbers, + label: context.l10n.audioAnalysisSamples, + value: _formatNumber(data.totalSamples), + cs: cs, + ), + ], + ), + ], + ), + ), + ); + } + + String _formatDuration(double seconds) { + final mins = seconds ~/ 60; + final secs = (seconds % 60).floor(); + return '$mins:${secs.toString().padLeft(2, '0')}'; + } + + String _formatFileSize(int bytes) { + if (bytes == 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + final i = (math.log(bytes) / math.log(1024)).floor(); + final size = bytes / math.pow(1024, i); + return '${size.toStringAsFixed(1)} ${units[i]}'; + } + + String _formatNumber(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K'; + return n.toString(); + } +} + +class _MetricChip extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final ColorScheme cs; + + const _MetricChip({ + required this.icon, + required this.label, + required this.value, + required this.cs, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + '$label: ', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +class _SpectrogramView extends StatelessWidget { + final ui.Image image; + final int sampleRate; + final double maxFreq; + + const _SpectrogramView({ + required this.image, + required this.sampleRate, + required this.maxFreq, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Card( + color: Colors.black, + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 2.0, + child: CustomPaint( + painter: _ImagePainter(image), + size: Size.infinite, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Text( + '${context.l10n.audioAnalysisSampleRate}: $sampleRate Hz', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + const Spacer(), + Text( + '${context.l10n.audioAnalysisNyquist}: ${(maxFreq / 1000).toStringAsFixed(1)} kHz', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ImagePainter extends CustomPainter { + final ui.Image image; + _ImagePainter(this.image); + + @override + void paint(Canvas canvas, Size size) { + paintImage( + canvas: canvas, + rect: Offset.zero & size, + image: image, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ); + } + + @override + bool shouldRepaint(covariant _ImagePainter old) => old.image != image; +} + +class _SpectrogramRenderParams { + final SpectrogramData spectrum; + final int width; + final int height; + + const _SpectrogramRenderParams({ + required this.spectrum, + required this.width, + required this.height, + }); +} + +Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) { + final w = params.width; + final h = params.height; + final spectrum = params.spectrum; + final pixels = Uint8List(w * h * 4); + + for (int i = 3; i < pixels.length; i += 4) { + pixels[i] = 255; + } + + final slices = spectrum.magnitudes; + if (slices.isEmpty) return pixels; + + final freqBins = spectrum.freqBins; + + double minDB = 0; + double maxDB = -200; + for (final slice in slices) { + for (int i = 0; i < slice.length; i++) { + final db = slice[i]; + if (db > maxDB) maxDB = db; + if (db < minDB && db > -200) minDB = db; + } + } + minDB = math.max(minDB, maxDB - 90); + final dbRange = maxDB - minDB; + if (dbRange <= 0) return pixels; + + for (int px = 0; px < w; px++) { + final t = (px / w * slices.length).floor().clamp(0, slices.length - 1); + final slice = slices[t]; + + for (int py = 0; py < h; py++) { + final freqRatio = 1.0 - (py / h); + final f = (freqRatio * freqBins).floor().clamp(0, freqBins - 1); + if (f >= slice.length) continue; + + final db = slice[f]; + final intensity = ((db - minDB) / dbRange).clamp(0.0, 1.0); + final color = _spekColorRGB(intensity); + + final offset = (py * w + px) * 4; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + pixels[offset + 3] = 255; + } + } + + return pixels; +} + +List _spekColorRGB(double intensity) { + int r, g, b; + if (intensity < 0.08) { + final t = intensity / 0.08; + r = 0; + g = 0; + b = (t * 80).floor(); + } else if (intensity < 0.18) { + final t = (intensity - 0.08) / 0.10; + r = (t * 50).floor(); + g = (t * 30).floor(); + b = (80 + t * 175).floor(); + } else if (intensity < 0.28) { + final t = (intensity - 0.18) / 0.10; + r = (50 + t * 150).floor(); + g = (30 - t * 30).floor(); + b = (255 - t * 55).floor(); + } else if (intensity < 0.40) { + final t = (intensity - 0.28) / 0.12; + r = (200 + t * 55).floor(); + g = 0; + b = (200 - t * 200).floor(); + } else if (intensity < 0.52) { + final t = (intensity - 0.40) / 0.12; + r = 255; + g = (t * 100).floor(); + b = 0; + } else if (intensity < 0.65) { + final t = (intensity - 0.52) / 0.13; + r = 255; + g = (100 + t * 80).floor(); + b = 0; + } else if (intensity < 0.78) { + final t = (intensity - 0.65) / 0.13; + r = 255; + g = (180 + t * 55).floor(); + b = (t * 30).floor(); + } else if (intensity < 0.90) { + final t = (intensity - 0.78) / 0.12; + r = 255; + g = (235 + t * 20).floor(); + b = (30 + t * 100).floor(); + } else { + final t = (intensity - 0.90) / 0.10; + r = 255; + g = 255; + b = (130 + t * 125).floor(); + } + return [r.clamp(0, 255), g.clamp(0, 255), b.clamp(0, 255)]; +} diff --git a/lib/widgets/bottom_sheet_option_tile.dart b/lib/widgets/bottom_sheet_option_tile.dart new file mode 100644 index 00000000..7f599a47 --- /dev/null +++ b/lib/widgets/bottom_sheet_option_tile.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// Reusable option tile for bottom sheets. +/// Used in playlist options, track options, cover options, etc. +class BottomSheetOptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const BottomSheetOptionTile({ + super.key, + 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, + ); + } +} diff --git a/lib/widgets/donate_icons.dart b/lib/widgets/donate_icons.dart index 21ba4195..fa34125a 100644 --- a/lib/widgets/donate_icons.dart +++ b/lib/widgets/donate_icons.dart @@ -166,7 +166,6 @@ class _GitHubPainter extends CustomPainter { 9.47 * scale, 17.93 * scale, 9.81 * scale, 17.63 * scale, ); - // Bottom path.cubicTo( 7.15 * scale, 17.33 * scale, 4.34 * scale, 16.33 * scale, diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 523970b9..dfff48cb 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -77,24 +77,6 @@ const _builtInServices = [ ), ], ), - BuiltInService( - id: 'youtube', - label: 'YouTube', - qualityOptions: [ - QualityOption( - id: 'opus_320', - label: 'Opus 320kbps', - description: 'Best quality lossy (~10MB per track)', - ), - QualityOption( - id: 'mp3_320', - label: 'MP3 320kbps', - description: 'Best compatibility (~10MB per track)', - ), - ], - isDisabled: false, - disabledReason: null, - ), ]; class DownloadServicePicker extends ConsumerStatefulWidget { @@ -128,7 +110,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { }) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -148,9 +130,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget { } class _DownloadServicePickerState extends ConsumerState { - static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; - static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; - late String _selectedService; @override @@ -167,30 +146,6 @@ class _DownloadServicePickerState extends ConsumerState { /// Get quality options for the selected service List _getQualityOptions() { - final settings = ref.read(settingsProvider); - if (_selectedService == 'youtube') { - final opusBitrate = _nearestSupportedBitrate( - settings.youtubeOpusBitrate, - _youtubeOpusSupportedBitrates, - ); - final mp3Bitrate = _nearestSupportedBitrate( - settings.youtubeMp3Bitrate, - _youtubeMp3SupportedBitrates, - ); - return [ - QualityOption( - id: 'opus_$opusBitrate', - label: 'Opus ${opusBitrate}kbps', - description: 'Configured from YouTube settings', - ), - QualityOption( - id: 'mp3_$mp3Bitrate', - label: 'MP3 ${mp3Bitrate}kbps', - description: 'Configured from YouTube settings', - ), - ]; - } - final builtIn = _builtInServices .where((s) => s.id == _selectedService) .firstOrNull; @@ -215,22 +170,6 @@ class _DownloadServicePickerState extends ConsumerState { ]; } - int _nearestSupportedBitrate(int value, List supported) { - var nearest = supported.first; - var nearestDistance = (value - nearest).abs(); - - for (final option in supported.skip(1)) { - final distance = (value - option).abs(); - if (distance < nearestDistance || - (distance == nearestDistance && option > nearest)) { - nearest = option; - nearestDistance = distance; - } - } - - return nearest; - } - @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -303,7 +242,9 @@ class _DownloadServicePickerState extends ConsumerState { ), for (final ext in downloadExtensions) _ServiceChip( - label: ext.displayName, + label: widget.recommendedService == ext.id + ? '${ext.displayName} (Recommended)' + : ext.displayName, isSelected: _selectedService == ext.id, onTap: () => setState(() => _selectedService = ext.id), iconPath: ext.iconPath, @@ -322,9 +263,7 @@ class _DownloadServicePickerState extends ConsumerState { ), ), - if (_builtInServices.any( - (s) => s.id == _selectedService && s.id != 'youtube', - )) + if (_builtInServices.any((s) => s.id == _selectedService)) Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), child: Text( @@ -336,18 +275,6 @@ class _DownloadServicePickerState extends ConsumerState { ), ), - if (_selectedService == 'youtube') - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - context.l10n.youtubeQualityNote, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - for (final quality in qualityOptions) _QualityOption( title: quality.label, diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index b8f75708..5fe9d3d4 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -19,7 +19,7 @@ class TrackCollectionQuickActions extends ConsumerWidget { Track track, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, diff --git a/pubspec.lock b/pubspec.lock index 5f81e3da..cc6cea93 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,14 +9,22 @@ packages: url: "https://pub.dev" source: hosted version: "91.0.0" + analysis_server_plugin: + dependency: transitive + description: + name: analysis_server_plugin + sha256: "26844e7f977087567135d62532b67d5639fe206c5194c3f410ba75e1a04a2747" + url: "https://pub.dev" + source: hosted + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "8.4.0" analyzer_buffer: dependency: transitive description: @@ -25,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.11" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53" + url: "https://pub.dev" + source: hosted + version: "0.13.10" archive: dependency: transitive description: @@ -145,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" cli_config: dependency: transitive description: @@ -241,6 +265,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8" + url: "https://pub.dev" + source: hosted + version: "1.0.0+8.4.0" dart_style: dependency: transitive description: @@ -925,6 +973,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0+1" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" + url: "https://pub.dev" + source: hosted + version: "3.1.0" rxdart: dependency: transitive description: @@ -1402,6 +1458,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" sdks: dart: ">=3.10.0 <4.0.0" flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 2bbf902a..ab896e79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 3.9.0+115 +version: 4.1.1+118 environment: sdk: ^3.10.0 @@ -13,7 +13,7 @@ dependencies: # Localization flutter_localizations: sdk: flutter - intl: any + intl: ^0.20.2 # State Management flutter_riverpod: ^3.1.0 @@ -68,7 +68,9 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 build_runner: ^2.10.4 + custom_lint: ^0.8.1 riverpod_generator: ^4.0.0 + riverpod_lint: ^3.1.0 json_serializable: ^6.11.2 flutter_launcher_icons: ^0.14.3