mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 03:15:51 +02:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f327cd1f6 | |||
| 4f2e677e8b | |||
| 79a69f8f70 | |||
| bf0f4bdf3e | |||
| 5e1cc3ecb5 | |||
| d4b37edc2f | |||
| 9483614bc7 | |||
| a73f2e1a13 | |||
| 091e3fadd9 | |||
| 5340ca7b16 | |||
| 85d3e58a26 | |||
| 1125c757fe | |||
| 66d714d368 | |||
| 49c2501fbc | |||
| e487817f21 | |||
| d8bbeb1e67 | |||
| 9693616645 | |||
| 0423e36d34 | |||
| c8d605fdee | |||
| 03fd734048 |
@@ -141,6 +141,11 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
|
||||
|
||||
</details>
|
||||
|
||||
> [!NOTE]
|
||||
> If SpotiFLAC is useful to you, consider supporting development:
|
||||
>
|
||||
> [](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:
|
||||
>
|
||||
> [](https://ko-fi.com/zarzet)
|
||||
|
||||
> [!TIP]
|
||||
> **Star the repo** to get notified about all new releases directly from GitHub.
|
||||
|
||||
@@ -137,14 +137,13 @@ class DownloadService : Service() {
|
||||
|
||||
private fun startForegroundService() {
|
||||
isRunning = true
|
||||
|
||||
// Acquire wake lock to prevent CPU sleep
|
||||
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = powerManager.newWakeLock(
|
||||
PowerManager.PARTIAL_WAKE_LOCK,
|
||||
WAKELOCK_TAG
|
||||
).apply {
|
||||
acquire(60 * 60 * 1000L) // 1 hour max
|
||||
acquire(60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
val notification = buildNotification(0, 0)
|
||||
|
||||
@@ -27,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) {
|
||||
""
|
||||
@@ -413,6 +401,38 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonValue(value: Any?): Any? {
|
||||
return when (value) {
|
||||
null, JSONObject.NULL -> null
|
||||
is JSONObject -> {
|
||||
val map = LinkedHashMap<String, Any?>()
|
||||
val keys = value.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
map[key] = parseJsonValue(value.opt(key))
|
||||
}
|
||||
map
|
||||
}
|
||||
is JSONArray -> {
|
||||
val list = ArrayList<Any?>()
|
||||
for (i in 0 until value.length()) {
|
||||
list.add(parseJsonValue(value.opt(i)))
|
||||
}
|
||||
list
|
||||
}
|
||||
is Number, is Boolean, is String -> value
|
||||
else -> value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseJsonPayload(payload: String): Any {
|
||||
return try {
|
||||
parseJsonValue(JSONTokener(payload).nextValue()) ?: payload
|
||||
} catch (_: Exception) {
|
||||
payload
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
|
||||
stopDownloadProgressStream()
|
||||
downloadProgressEventSink = sink
|
||||
@@ -425,7 +445,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
if (payload != lastDownloadProgressPayload) {
|
||||
lastDownloadProgressPayload = payload
|
||||
sink.success(payload)
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
@@ -457,7 +477,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
if (payload != lastLibraryScanProgressPayload) {
|
||||
lastLibraryScanProgressPayload = payload
|
||||
sink.success(payload)
|
||||
sink.success(parseJsonPayload(payload))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w(
|
||||
@@ -599,7 +619,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
|
||||
*/
|
||||
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
|
||||
// Try DISPLAY_NAME first
|
||||
try {
|
||||
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
@@ -610,7 +629,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Try MIME_TYPE
|
||||
try {
|
||||
val mime = contentResolver.getType(uri)
|
||||
val ext = extFromMimeType(mime)
|
||||
@@ -836,8 +854,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val mimeType = mimeTypeForExt(outputExt)
|
||||
val fileName = buildSafFileName(req, outputExt)
|
||||
|
||||
// Check for existing file WITHOUT creating the directory first.
|
||||
// This prevents empty folders from being created for duplicate downloads.
|
||||
val existingDir = findDocumentDir(treeUri, relativeDir)
|
||||
if (existingDir != null) {
|
||||
val existing = existingDir.findFile(fileName)
|
||||
@@ -852,7 +868,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Only create the directory now that we know we need to download
|
||||
val targetDir = ensureDocumentDir(treeUri, relativeDir)
|
||||
?: return errorJson("Failed to access SAF directory")
|
||||
|
||||
@@ -875,7 +890,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val respObj = JSONObject(response)
|
||||
if (respObj.optBoolean("success", false)) {
|
||||
// Extension providers write to a local temp path instead of the SAF FD.
|
||||
// Copy the local file into the SAF document so it is not empty.
|
||||
val goFilePath = respObj.optString("file_path", "")
|
||||
if (goFilePath.isNotEmpty() &&
|
||||
!goFilePath.startsWith("content://") &&
|
||||
@@ -924,15 +938,10 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
try {
|
||||
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
|
||||
if (docId.isNullOrEmpty()) return null
|
||||
|
||||
// Document IDs typically look like "primary:Music/Album/file.cue"
|
||||
// Parent would be "primary:Music/Album"
|
||||
val lastSlash = docId.lastIndexOf('/')
|
||||
if (lastSlash <= 0) return null
|
||||
|
||||
val parentDocId = docId.substring(0, lastSlash)
|
||||
|
||||
// Build a tree document URI for the parent so it supports listing/findFile
|
||||
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
|
||||
if (treeDocId.isNullOrEmpty()) return null
|
||||
|
||||
@@ -957,21 +966,17 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val lines = File(cueTempPath).readLines()
|
||||
for (line in lines) {
|
||||
val trimmed = line.trim().let { l ->
|
||||
// Strip BOM
|
||||
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
|
||||
}
|
||||
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
|
||||
val rest = trimmed.substring(5).trim()
|
||||
// Parse: "filename" TYPE or filename TYPE
|
||||
val filename = if (rest.startsWith("\"")) {
|
||||
val endQuote = rest.indexOf('"', 1)
|
||||
if (endQuote > 0) rest.substring(1, endQuote) else rest
|
||||
} else {
|
||||
// Last word is the type, everything else is the filename
|
||||
val parts = rest.split("\\s+".toRegex())
|
||||
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
|
||||
}
|
||||
// Return just the filename (strip any path separators)
|
||||
return filename.substringAfterLast("/").substringAfterLast("\\")
|
||||
}
|
||||
}
|
||||
@@ -1056,7 +1061,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
|
||||
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
|
||||
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||
@@ -1141,7 +1145,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
var scanned = 0
|
||||
var errors = traversalErrors
|
||||
|
||||
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
|
||||
val cueReferencedAudioUris = mutableSetOf<String>()
|
||||
|
||||
for ((cueDoc, parentDir) in cueFiles) {
|
||||
@@ -1180,10 +1183,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark this audio file so we skip it in the regular audio pass
|
||||
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||
|
||||
// Copy audio to same temp dir so Go can resolve it
|
||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
@@ -1197,7 +1198,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Rename temp audio to its original name so Go can find it by name
|
||||
val renamedAudio = File(tempDir, audioName)
|
||||
val tempAudioFile = File(tempAudioPath)
|
||||
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
||||
@@ -1240,14 +1240,12 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
||||
for ((doc, _) in audioFiles) {
|
||||
if (safScanCancel) {
|
||||
updateSafScanProgress { it.isComplete = true }
|
||||
return "[]"
|
||||
}
|
||||
|
||||
// Skip audio files that are represented by CUE track entries
|
||||
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||
scanned++
|
||||
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
|
||||
@@ -1326,7 +1324,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
// Parse existing files map: URI -> lastModified
|
||||
val existingFiles = mutableMapOf<String, Long>()
|
||||
try {
|
||||
val obj = JSONObject(existingFilesJson)
|
||||
@@ -1345,20 +1342,15 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
|
||||
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
|
||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
|
||||
// CUE files to scan: (cueDoc, parentDir, lastModified)
|
||||
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
|
||||
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
|
||||
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
|
||||
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
|
||||
val currentUris = mutableSetOf<String>()
|
||||
val visitedDirUris = mutableSetOf<String>()
|
||||
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
|
||||
var traversalErrors = 0
|
||||
|
||||
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
|
||||
// Virtual paths look like "content://...album.cue#track01".
|
||||
// We need this to preserve virtual paths for unchanged CUE files.
|
||||
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
|
||||
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>()
|
||||
for (key in existingFiles.keys) {
|
||||
val hashIdx = key.indexOf("#track")
|
||||
if (hashIdx > 0) {
|
||||
@@ -1367,7 +1359,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all files with lastModified
|
||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||
queue.add(root to "")
|
||||
|
||||
@@ -1423,8 +1414,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
queue.add(child to childPath)
|
||||
} else if (child.isFile) {
|
||||
// Mark file as present first so it cannot be mis-classified as removed
|
||||
// when provider-specific metadata calls (e.g., lastModified) fail.
|
||||
val uriStr = child.uri.toString()
|
||||
currentUris.add(uriStr)
|
||||
|
||||
@@ -1436,18 +1425,15 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
child.lastModified()
|
||||
} catch (_: Exception) { 0L }
|
||||
|
||||
// Check if any virtual track from this CUE exists with matching modTime
|
||||
val virtualPaths = existingCueVirtualPaths[uriStr]
|
||||
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
|
||||
|
||||
if (existingModified != null && existingModified == lastModified) {
|
||||
// CUE is unchanged — mark virtual paths as current so they aren't removed
|
||||
unchangedCueFiles.add(child to dir)
|
||||
for (vp in virtualPaths) {
|
||||
currentUris.add(vp)
|
||||
}
|
||||
} else {
|
||||
// CUE is new or modified — needs scanning
|
||||
cueFilesToScan.add(Triple(child, dir, lastModified))
|
||||
}
|
||||
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
|
||||
@@ -1458,7 +1444,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
existingModified ?: 0L
|
||||
}
|
||||
|
||||
// Check if file is new or modified
|
||||
if (existingModified == null || existingModified != lastModified) {
|
||||
audioFiles.add(Triple(child, path, lastModified))
|
||||
}
|
||||
@@ -1475,7 +1460,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed files (in existing but not in current)
|
||||
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||
val totalFiles = currentUris.size
|
||||
val filesToProcess = audioFiles.size + cueFilesToScan.size
|
||||
@@ -1503,7 +1487,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
var scanned = 0
|
||||
var errors = traversalErrors
|
||||
|
||||
// --- CUE first pass: parse new/modified CUE sheets ---
|
||||
val cueReferencedAudioUris = mutableSetOf<String>()
|
||||
|
||||
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
|
||||
@@ -1524,7 +1507,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
var tempCuePath: String? = null
|
||||
var tempAudioPath: String? = null
|
||||
try {
|
||||
// Copy CUE to temp
|
||||
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
|
||||
if (tempCuePath == null) {
|
||||
errors++
|
||||
@@ -1533,10 +1515,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the audio filename from the CUE sheet text
|
||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||
|
||||
// Find the referenced audio file as a sibling in the same SAF directory
|
||||
val audioDoc = resolveCueAudioSibling(
|
||||
parentDir = parentDir,
|
||||
cueName = cueName,
|
||||
@@ -1551,10 +1531,8 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark this audio file so we skip it in the regular audio pass
|
||||
cueReferencedAudioUris.add(audioDoc.uri.toString())
|
||||
|
||||
// Copy audio to same temp dir so Go can resolve it
|
||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
@@ -1568,7 +1546,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Rename temp audio to its original name so Go can find it by name
|
||||
val renamedAudio = File(tempDir, audioName)
|
||||
val tempAudioFile = File(tempAudioPath)
|
||||
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
|
||||
@@ -1576,7 +1553,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
tempAudioPath = renamedAudio.absolutePath
|
||||
}
|
||||
|
||||
// Call Go to produce library scan entries for each CUE track
|
||||
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
|
||||
tempCuePath,
|
||||
tempDir,
|
||||
@@ -1588,7 +1564,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
for (j in 0 until cueArray.length()) {
|
||||
val trackObj = cueArray.getJSONObject(j)
|
||||
results.put(trackObj)
|
||||
// Register each virtual path as current so deletion detection works
|
||||
val virtualPath = trackObj.optString("filePath", "")
|
||||
if (virtualPath.isNotBlank()) {
|
||||
currentUris.add(virtualPath)
|
||||
@@ -1621,9 +1596,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Discover audio siblings for unchanged CUE files so we skip them
|
||||
// in the regular audio pass. Copy the .cue to temp (tiny file) to extract
|
||||
// the audio filename, then find the sibling by name.
|
||||
for ((cueDoc, parentDir) in unchangedCueFiles) {
|
||||
var tempCue: String? = null
|
||||
try {
|
||||
@@ -1648,7 +1620,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Regular audio file pass: skip files referenced by CUE sheets ---
|
||||
for ((doc, _, lastModified) in audioFiles) {
|
||||
if (safScanCancel) {
|
||||
updateSafScanProgress { it.isComplete = true }
|
||||
@@ -1661,7 +1632,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
// Skip audio files that are represented by CUE track entries
|
||||
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
|
||||
scanned++
|
||||
val processed = skippedCount + scanned
|
||||
@@ -1715,7 +1685,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate removedUris now that CUE virtual paths have been registered
|
||||
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
|
||||
|
||||
updateSafScanProgress {
|
||||
@@ -1893,7 +1862,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
// Update the intent so receive_sharing_intent can access the new data
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
@@ -2000,13 +1968,13 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getDownloadProgress()
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"getAllDownloadProgress" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAllDownloadProgress()
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"initItemProgress" -> {
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
@@ -2553,7 +2521,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
val tempPath = copyUriToTemp(uri)
|
||||
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
|
||||
try {
|
||||
// Replace file_path with temp path for Go
|
||||
reqObj.put("file_path", tempPath)
|
||||
val raw = Gobackend.reEnrichFile(reqObj.toString())
|
||||
val obj = JSONObject(raw)
|
||||
@@ -2631,7 +2598,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Deezer API methods
|
||||
"searchDeezerAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
@@ -2642,7 +2608,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Tidal search API
|
||||
"searchTidalAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
@@ -2653,7 +2618,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Qobuz search API
|
||||
"searchQobuzAll" -> {
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
val trackLimit = call.argument<Int>("track_limit") ?: 15
|
||||
@@ -2783,7 +2747,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Log methods
|
||||
"getLogs" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getLogs()
|
||||
@@ -2816,7 +2779,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension System methods
|
||||
"initExtensionSystem" -> {
|
||||
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
|
||||
val dataDir = call.argument<String>("data_dir") ?: ""
|
||||
@@ -2961,7 +2923,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension Auth API methods
|
||||
"getExtensionPendingAuth" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -3011,7 +2972,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension FFmpeg API
|
||||
"getPendingFFmpegCommand" -> {
|
||||
val commandId = call.argument<String>("command_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -3039,7 +2999,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Custom Search API
|
||||
"customSearchWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val query = call.argument<String>("query") ?: ""
|
||||
@@ -3055,7 +3014,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension URL Handler API
|
||||
"handleURLWithExtension" -> {
|
||||
val url = call.argument<String>("url") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -3100,7 +3058,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Post-Processing API
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
val metadataJson = call.argument<String>("metadata") ?: ""
|
||||
@@ -3144,7 +3101,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Store
|
||||
"initExtensionStore" -> {
|
||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -3206,7 +3162,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
// Extension Home Feed (Explore)
|
||||
"getExtensionHomeFeed" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
@@ -3221,7 +3176,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Local Library Scanning
|
||||
"setLibraryCoverCacheDir" -> {
|
||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -3298,7 +3252,7 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
Gobackend.getLibraryScanProgressJSON()
|
||||
}
|
||||
}
|
||||
result.success(response)
|
||||
result.success(parseJsonPayload(response))
|
||||
}
|
||||
"cancelLibraryScan" -> {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -3326,7 +3280,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// CUE Sheet Parsing
|
||||
"parseCueSheet" -> {
|
||||
val cuePath = call.argument<String>("cue_path") ?: ""
|
||||
val audioDir = call.argument<String>("audio_dir") ?: ""
|
||||
@@ -3338,17 +3291,14 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
|
||||
var tempAudioPath: String? = null
|
||||
try {
|
||||
// Extract audio filename from CUE text
|
||||
val audioFileName = extractCueAudioFileName(tempCuePath)
|
||||
|
||||
// Try to find the audio sibling in SAF
|
||||
var audioDoc: DocumentFile? = null
|
||||
val parentDir = safParentDir(uri)
|
||||
if (parentDir != null && !audioFileName.isNullOrBlank()) {
|
||||
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// Fallback: try common extensions with the CUE base name
|
||||
if (audioDoc == null && parentDir != null) {
|
||||
val cueName = try {
|
||||
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
|
||||
@@ -3367,7 +3317,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
|
||||
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
|
||||
if (audioDoc != null) {
|
||||
// Copy audio to same temp dir with original name
|
||||
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
|
||||
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
|
||||
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
|
||||
@@ -3382,15 +3331,11 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse with audio in temp dir; Go will resolve there
|
||||
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
|
||||
|
||||
// Replace the temp audio_path with the SAF content:// URI
|
||||
// so Dart knows it's a SAF file and handles it accordingly
|
||||
if (audioDoc != null) {
|
||||
val resultObj = JSONObject(resultJson)
|
||||
resultObj.put("audio_path", audioDoc.uri.toString())
|
||||
// Also pass the original CUE URI for reference
|
||||
resultObj.put("cue_path", cuePath)
|
||||
resultObj.toString()
|
||||
} else {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func ffmpegCommand(args ...string) *exec.Cmd {
|
||||
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return exec.Command(ffmpegPath, args...)
|
||||
}
|
||||
return exec.Command("ffmpeg", args...)
|
||||
}
|
||||
|
||||
func runFFmpegTestCommand(t *testing.T, args ...string) {
|
||||
t.Helper()
|
||||
cmd := ffmpegCommand(args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not available")
|
||||
}
|
||||
|
||||
tempDir := t.TempDir()
|
||||
sourceFlac := filepath.Join(tempDir, "source.flac")
|
||||
baseMp3 := filepath.Join(tempDir, "base.mp3")
|
||||
finalMp3 := filepath.Join(tempDir, "final.mp3")
|
||||
coverPath := filepath.Join(tempDir, "cover.jpg")
|
||||
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=1",
|
||||
"-c:a",
|
||||
"flac",
|
||||
sourceFlac,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=red:s=32x32:d=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
coverPath,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
sourceFlac,
|
||||
"-b:a",
|
||||
"320k",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
baseMp3,
|
||||
)
|
||||
|
||||
runFFmpegTestCommand(
|
||||
t,
|
||||
"-y",
|
||||
"-i",
|
||||
baseMp3,
|
||||
"-i",
|
||||
coverPath,
|
||||
"-map",
|
||||
"0:a",
|
||||
"-map_metadata",
|
||||
"-1",
|
||||
"-map",
|
||||
"1:0",
|
||||
"-c:v:0",
|
||||
"copy",
|
||||
"-id3v2_version",
|
||||
"3",
|
||||
"-metadata",
|
||||
"title=Test Song",
|
||||
"-metadata",
|
||||
"artist=Test Artist",
|
||||
"-metadata",
|
||||
"lyrics="+lyrics,
|
||||
"-metadata:s:v",
|
||||
"title=Album cover",
|
||||
"-metadata:s:v",
|
||||
"comment=Cover (front)",
|
||||
"-c:a",
|
||||
"copy",
|
||||
finalMp3,
|
||||
)
|
||||
|
||||
meta, err := ReadID3Tags(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadID3Tags failed: %v", err)
|
||||
}
|
||||
if meta == nil {
|
||||
t.Fatalf("ReadID3Tags returned nil metadata")
|
||||
}
|
||||
|
||||
embeddedLyrics, err := ExtractLyrics(finalMp3)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
|
||||
}
|
||||
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
|
||||
}
|
||||
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
|
||||
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(finalMp3); err != nil {
|
||||
t.Fatalf("expected final mp3 to exist: %v", err)
|
||||
}
|
||||
}
|
||||
+34
-4
@@ -17,6 +17,8 @@ const (
|
||||
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||
|
||||
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
|
||||
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
// Log already printed by upgradeToMaxQuality for Deezer
|
||||
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||
}
|
||||
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
}
|
||||
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify CDN upgrade
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
// Deezer CDN upgrade
|
||||
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||
return upgradeDeezerCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return upgradeTidalCover(coverURL)
|
||||
}
|
||||
|
||||
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return upgradeQobuzCover(coverURL)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
}
|
||||
|
||||
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeTidalCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Tidal: upgraded to origin resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func upgradeQobuzCover(coverURL string) string {
|
||||
if !strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return coverURL
|
||||
}
|
||||
|
||||
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
|
||||
if upgraded != coverURL {
|
||||
GoLog("[Cover] Qobuz: upgraded to max resolution")
|
||||
}
|
||||
return upgraded
|
||||
}
|
||||
|
||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
if imageURL == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Always upgrade small to medium first
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
|
||||
// CueSheet represents a parsed .cue file
|
||||
type CueSheet struct {
|
||||
// Album-level metadata
|
||||
Performer string `json:"performer"`
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
@@ -32,7 +31,6 @@ type CueTrack struct {
|
||||
Performer string `json:"performer"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
Composer string `json:"composer,omitempty"`
|
||||
// Index positions in seconds (fractional)
|
||||
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
|
||||
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
|
||||
}
|
||||
@@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle BOM at start of file
|
||||
if strings.HasPrefix(line, "\xef\xbb\xbf") {
|
||||
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
|
||||
line = strings.TrimSpace(line)
|
||||
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
|
||||
upper := strings.ToUpper(line)
|
||||
|
||||
// REM commands (album-level metadata)
|
||||
if strings.HasPrefix(upper, "REM ") {
|
||||
matches := reRemCommand.FindStringSubmatch(line)
|
||||
if len(matches) == 3 {
|
||||
@@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
|
||||
if strings.HasPrefix(upper, "FILE ") {
|
||||
rest := line[len("FILE "):]
|
||||
// Extract filename and type
|
||||
// Format: FILE "filename.flac" WAVE
|
||||
// or: FILE filename.flac WAVE
|
||||
fname, ftype := parseCueFileLine(rest)
|
||||
sheet.FileName = fname
|
||||
sheet.FileType = ftype
|
||||
@@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
}
|
||||
|
||||
if strings.HasPrefix(upper, "TRACK ") {
|
||||
// Save previous track
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
@@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// SONGWRITER (used as composer sometimes)
|
||||
if strings.HasPrefix(upper, "SONGWRITER ") {
|
||||
value := unquoteCue(line[len("SONGWRITER "):])
|
||||
if currentTrack != nil {
|
||||
@@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last track
|
||||
if currentTrack != nil {
|
||||
sheet.Tracks = append(sheet.Tracks, *currentTrack)
|
||||
}
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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
|
||||
|
||||
+58
-126
@@ -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,
|
||||
@@ -3226,11 +3160,7 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
|
||||
return "", fmt.Errorf("extension store not initialized")
|
||||
}
|
||||
|
||||
if forceRefresh {
|
||||
store.FetchRegistry(true)
|
||||
}
|
||||
|
||||
extensions, err := store.GetExtensionsWithStatus()
|
||||
extensions, err := store.getExtensionsWithStatus(forceRefresh)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+241
-114
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
|
||||
}
|
||||
|
||||
type LoadedExtension struct {
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
ID string `json:"id"`
|
||||
Manifest *ExtensionManifest `json:"manifest"`
|
||||
VM *goja.Runtime `json:"-"`
|
||||
VMMu sync.Mutex `json:"-"`
|
||||
runtime *ExtensionRuntime
|
||||
initialized bool
|
||||
Enabled bool `json:"enabled"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DataDir string `json:"data_dir"`
|
||||
SourceDir string `json:"source_dir"`
|
||||
IconPath string `json:"icon_path"`
|
||||
}
|
||||
|
||||
func getExtensionInitSettings(extensionID string) map[string]interface{} {
|
||||
settings := GetExtensionSettingsStore().GetAll(extensionID)
|
||||
if len(settings) == 0 {
|
||||
return settings
|
||||
}
|
||||
|
||||
filtered := make(map[string]interface{}, len(settings))
|
||||
for key, value := range settings {
|
||||
if strings.HasPrefix(key, "_") {
|
||||
continue
|
||||
}
|
||||
filtered[key] = value
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
|
||||
if ext.VM == nil || ext.runtime == nil {
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if applyStoredSettings && !ext.initialized {
|
||||
settings := getExtensionInitSettings(ext.ID)
|
||||
if len(settings) > 0 {
|
||||
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
|
||||
teardownVMLocked(ext)
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ext.initialized = true
|
||||
}
|
||||
}
|
||||
|
||||
ext.Error = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) ensureRuntimeReady() error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
return ensureRuntimeReadyLocked(ext, true)
|
||||
}
|
||||
|
||||
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
|
||||
ext.VMMu.Lock()
|
||||
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
|
||||
ext.VMMu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return ext.VM, nil
|
||||
}
|
||||
|
||||
type ExtensionManager struct {
|
||||
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
||||
return ext, nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
func initializeVMLocked(ext *LoadedExtension) error {
|
||||
ext.VM = nil
|
||||
ext.runtime = nil
|
||||
ext.initialized = false
|
||||
vm := goja.New()
|
||||
ext.VM = vm
|
||||
|
||||
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
return initializeVMLocked(ext)
|
||||
}
|
||||
|
||||
func initializeExtensionWithSettingsLocked(
|
||||
ext *LoadedExtension,
|
||||
settings map[string]interface{},
|
||||
) error {
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ext.initialized = true
|
||||
GoLog("[Extension] Initialized %s\n", ext.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runCleanupLocked(ext *LoadedExtension) error {
|
||||
if ext.VM != nil {
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func teardownVMLocked(ext *LoadedExtension) {
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
}
|
||||
ext.runtime = nil
|
||||
ext.VM = nil
|
||||
ext.initialized = false
|
||||
}
|
||||
|
||||
func validateExtensionLoad(ext *LoadedExtension) error {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
if err := initializeVMLocked(ext); err != nil {
|
||||
return err
|
||||
}
|
||||
teardownVMLocked(ext)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM != nil {
|
||||
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
|
||||
if err != nil {
|
||||
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
|
||||
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
|
||||
GoLog("[Extension] Cleanup called for %s\n", extensionID)
|
||||
}
|
||||
}
|
||||
if ext.runtime != nil {
|
||||
if err := ext.runtime.flushStorageNow(); err != nil {
|
||||
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
|
||||
}
|
||||
ext.runtime.closeStorageFlusher()
|
||||
ext.runtime = nil
|
||||
}
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
|
||||
delete(m.extensions, extensionID)
|
||||
GoLog("[Extension] Unloaded extension: %s\n", extensionID)
|
||||
@@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
ext.Enabled = enabled
|
||||
if enabled {
|
||||
ext.Enabled = true
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
store := GetExtensionSettingsStore()
|
||||
ext.Enabled = false
|
||||
_ = store.Set(extensionID, "_enabled", false)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
ext.Enabled = false
|
||||
ext.Error = ""
|
||||
ext.VMMu.Lock()
|
||||
teardownVMLocked(ext)
|
||||
ext.VMMu.Unlock()
|
||||
}
|
||||
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
|
||||
|
||||
store := GetExtensionSettingsStore()
|
||||
@@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
|
||||
}
|
||||
|
||||
m.extensions[manifest.Name] = ext
|
||||
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
||||
SourceDir: extDir,
|
||||
}
|
||||
|
||||
if err := m.initializeVM(ext); err != nil {
|
||||
if wasEnabled {
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
} else if err := validateExtensionLoad(ext); err != nil {
|
||||
ext.Error = err.Error()
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err)
|
||||
GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
||||
return fmt.Errorf("Extension not found")
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||
}
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
|
||||
settingsJSON, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to save settings")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
var settings = %s;
|
||||
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
|
||||
try {
|
||||
extension.initialize(settings);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no initialize function' };
|
||||
})()
|
||||
`, string(settingsJSON))
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.Error = fmt.Sprintf("initialize failed: %v", err)
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
|
||||
if err := ensureRuntimeReadyLocked(ext, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
ext.Error = errMsg
|
||||
ext.Enabled = false
|
||||
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("initialize failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Initialized %s\n", extensionID)
|
||||
return nil
|
||||
return initializeExtensionWithSettingsLocked(ext, settings)
|
||||
}
|
||||
|
||||
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
||||
if ext.VM == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
script := `
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
|
||||
try {
|
||||
extension.cleanup();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.toString() };
|
||||
}
|
||||
}
|
||||
return { success: true, message: 'no cleanup function' };
|
||||
})()
|
||||
`
|
||||
|
||||
result, err := ext.VM.RunString(script)
|
||||
if err != nil {
|
||||
ext.VMMu.Lock()
|
||||
defer ext.VMMu.Unlock()
|
||||
if err := runCleanupLocked(ext); err != nil {
|
||||
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result != nil && !goja.IsUndefined(result) {
|
||||
exported := result.Export()
|
||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||
if success, ok := resultMap["success"].(bool); ok && !success {
|
||||
errMsg := "unknown error"
|
||||
if e, ok := resultMap["error"].(string); ok {
|
||||
errMsg = e
|
||||
}
|
||||
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
|
||||
return fmt.Errorf("cleanup failed: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Extension] Cleaned up %s\n", extensionID)
|
||||
return nil
|
||||
}
|
||||
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
|
||||
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||
}
|
||||
|
||||
if ext.VM == nil {
|
||||
return nil, fmt.Errorf("extension VM not initialized")
|
||||
if err := ext.ensureRuntimeReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !ext.Enabled {
|
||||
|
||||
@@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) lockReadyVM() error {
|
||||
vm, err := p.extension.lockReadyVM()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.vm = vm
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
|
||||
@@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
|
||||
if !p.extension.Enabled {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
|
||||
return track, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
trackJSON, err := json.Marshal(track)
|
||||
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -501,8 +518,13 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &ExtDownloadResult{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
ErrorType: "init_error",
|
||||
}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
|
||||
@@ -1626,8 +1648,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
if options == nil {
|
||||
@@ -1707,8 +1730,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
@@ -1792,8 +1816,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
sourceJSON, _ := json.Marshal(sourceTrack)
|
||||
@@ -1862,8 +1887,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
@@ -1924,8 +1950,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return &PostProcessResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
metadataJSON, _ := json.Marshal(metadata)
|
||||
@@ -2182,8 +2209,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
|
||||
if !p.extension.Enabled {
|
||||
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
|
||||
}
|
||||
|
||||
p.extension.VMMu.Lock()
|
||||
if err := p.lockReadyVM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer p.extension.VMMu.Unlock()
|
||||
|
||||
// Use global variables to avoid JS injection issues with special characters in track/artist names
|
||||
|
||||
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
|
||||
HasUpdate bool `json:"has_update"`
|
||||
}
|
||||
|
||||
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
|
||||
return StoreExtensionResponse{
|
||||
func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
|
||||
return &StoreExtensionResponse{
|
||||
ID: e.ID,
|
||||
Name: e.Name,
|
||||
DisplayName: e.getDisplayName(),
|
||||
@@ -236,7 +236,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
|
||||
// Check if a registry URL has been configured
|
||||
if s.registryURL == "" {
|
||||
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
|
||||
}
|
||||
@@ -289,8 +288,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
|
||||
return ®istry, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) {
|
||||
registry, err := s.FetchRegistry(forceRefresh)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -304,22 +303,29 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]StoreExtensionResponse, len(registry.Extensions))
|
||||
for i, ext := range registry.Extensions {
|
||||
resp := ext.ToResponse()
|
||||
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
|
||||
|
||||
result := make([]*StoreExtensionResponse, 0, len(registry.Extensions))
|
||||
for i := range registry.Extensions {
|
||||
ext := ®istry.Extensions[i]
|
||||
resp := ext.ToResponse()
|
||||
if installedVersion, ok := installed[ext.ID]; ok {
|
||||
resp.IsInstalled = true
|
||||
resp.InstalledVersion = installedVersion
|
||||
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
|
||||
}
|
||||
|
||||
result[i] = resp
|
||||
result = append(result, resp)
|
||||
}
|
||||
|
||||
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) {
|
||||
return s.getExtensionsWithStatus(false)
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
|
||||
registry, err := s.FetchRegistry(false)
|
||||
if err != nil {
|
||||
@@ -389,7 +395,6 @@ func ResolveRegistryURL(input string) (string, error) {
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// Try to match https://github.com/<owner>/<repo>[/...]
|
||||
const ghPrefix = "https://github.com/"
|
||||
if !strings.HasPrefix(input, ghPrefix) {
|
||||
// Also accept http:// and upgrade silently.
|
||||
@@ -470,7 +475,7 @@ func (s *ExtensionStore) GetCategories() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) {
|
||||
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) {
|
||||
extensions, err := s.GetExtensionsWithStatus()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -480,7 +485,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
var result []StoreExtensionResponse
|
||||
result := make([]*StoreExtensionResponse, 0, len(extensions))
|
||||
queryLower := toLower(query)
|
||||
|
||||
for _, ext := range extensions {
|
||||
@@ -493,7 +498,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
|
||||
!containsIgnoreCase(ext.DisplayName, queryLower) &&
|
||||
!containsIgnoreCase(ext.Description, queryLower) &&
|
||||
!containsIgnoreCase(ext.Author, queryLower) {
|
||||
// Check tags
|
||||
found := false
|
||||
for _, tag := range ext.Tags {
|
||||
if containsIgnoreCase(tag, queryLower) {
|
||||
|
||||
+1
-1
@@ -12,6 +12,7 @@ require (
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/text v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -24,6 +25,5 @@ require (
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
)
|
||||
|
||||
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
|
||||
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
|
||||
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
|
||||
@@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
|
||||
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
|
||||
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
|
||||
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
|
||||
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for ISP blocking via HTTP status codes
|
||||
// Some ISPs return 403 or 451 when blocking content
|
||||
if resp.StatusCode == 403 || resp.StatusCode == 451 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
|
||||
// Check if response looks like ISP blocking page
|
||||
ispBlockingIndicators := []string{
|
||||
"blocked", "forbidden", "access denied", "not available in your",
|
||||
"restricted", "censored", "unavailable for legal", "blocked by",
|
||||
@@ -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
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+24
-5
@@ -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{
|
||||
|
||||
+5
-7
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,750 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type YouTubeDownloader struct {
|
||||
client *http.Client
|
||||
apiURL string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
const spotubeBaseURL = "https://spotubedl.com"
|
||||
|
||||
var (
|
||||
globalYouTubeDownloader *YouTubeDownloader
|
||||
youtubeDownloaderOnce sync.Once
|
||||
)
|
||||
|
||||
type YouTubeQuality string
|
||||
|
||||
const (
|
||||
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
|
||||
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
|
||||
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
|
||||
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
|
||||
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
|
||||
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
|
||||
)
|
||||
|
||||
var (
|
||||
youtubeOpusSupportedBitrates = []int{128, 256, 320}
|
||||
youtubeMp3SupportedBitrates = []int{128, 256, 320}
|
||||
)
|
||||
|
||||
type CobaltRequest struct {
|
||||
URL string `json:"url"`
|
||||
AudioBitrate string `json:"audioBitrate,omitempty"`
|
||||
AudioFormat string `json:"audioFormat,omitempty"`
|
||||
DownloadMode string `json:"downloadMode,omitempty"`
|
||||
FilenameStyle string `json:"filenameStyle,omitempty"`
|
||||
DisableMetadata bool `json:"disableMetadata,omitempty"`
|
||||
}
|
||||
|
||||
type CobaltResponse struct {
|
||||
Status string `json:"status"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Error *struct {
|
||||
Code string `json:"code"`
|
||||
Context *struct {
|
||||
Service string `json:"service,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
} `json:"context,omitempty"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type YouTubeDownloadResult struct {
|
||||
FilePath string
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
ReleaseDate string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
ISRC string
|
||||
Format string // "opus" or "mp3"
|
||||
Bitrate int
|
||||
LyricsLRC string
|
||||
CoverData []byte
|
||||
}
|
||||
|
||||
func NewYouTubeDownloader() *YouTubeDownloader {
|
||||
youtubeDownloaderOnce.Do(func() {
|
||||
globalYouTubeDownloader = &YouTubeDownloader{
|
||||
client: NewHTTPClientWithTimeout(DownloadTimeout),
|
||||
apiURL: "https://api.qwkuns.me",
|
||||
}
|
||||
})
|
||||
return globalYouTubeDownloader
|
||||
}
|
||||
|
||||
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
|
||||
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return (r < '0' || r > '9')
|
||||
})
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := parts[i]
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
if parsed, err := strconv.Atoi(part); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return defaultBitrate
|
||||
}
|
||||
|
||||
func nearestSupportedBitrate(value int, supported []int) int {
|
||||
nearest := supported[0]
|
||||
nearestDistance := absInt(value - nearest)
|
||||
|
||||
for _, option := range supported[1:] {
|
||||
distance := absInt(value - option)
|
||||
// On tie prefer higher quality.
|
||||
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
|
||||
nearest = option
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return nearest
|
||||
}
|
||||
|
||||
func absInt(value int) int {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
|
||||
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "opus") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 256)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
|
||||
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(normalizedRaw, "mp3") {
|
||||
parsed := extractBitrateFromQuality(normalizedRaw, 320)
|
||||
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
|
||||
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
|
||||
}
|
||||
|
||||
// Backward compatibility for legacy symbolic values.
|
||||
switch normalizedRaw {
|
||||
case "opus_256", "opus256", "opus":
|
||||
return "opus", 256, YouTubeQualityOpus256
|
||||
case "opus_320", "opus320":
|
||||
return "opus", 320, YouTubeQualityOpus320
|
||||
case "opus_128", "opus128":
|
||||
return "opus", 128, YouTubeQualityOpus128
|
||||
case "mp3_320", "mp3320", "mp3", "":
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
case "mp3_256", "mp3256":
|
||||
return "mp3", 256, YouTubeQualityMP3256
|
||||
case "mp3_128", "mp3128":
|
||||
return "mp3", 128, YouTubeQualityMP3128
|
||||
default:
|
||||
return "mp3", 320, YouTubeQualityMP3320
|
||||
}
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
|
||||
query := fmt.Sprintf("%s %s", artistName, trackName)
|
||||
searchQuery := url.QueryEscape(query)
|
||||
|
||||
GoLog("[YouTube] Search query: %s\n", query)
|
||||
|
||||
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
|
||||
|
||||
return youtubeMusicURL, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
|
||||
y.mu.Lock()
|
||||
defer y.mu.Unlock()
|
||||
|
||||
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
|
||||
audioBitrate := strconv.Itoa(bitrate)
|
||||
|
||||
// Try SpotubeDL first (primary)
|
||||
var spotubeErr error
|
||||
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
|
||||
if extractErr == nil {
|
||||
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
|
||||
videoID, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
spotubeErr = err
|
||||
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
|
||||
} else {
|
||||
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
|
||||
}
|
||||
|
||||
// Fallback: direct Cobalt API (api.qwkuns.me)
|
||||
cobaltURL := toYouTubeMusicURL(youtubeURL)
|
||||
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
|
||||
cobaltURL, audioFormat, audioBitrate)
|
||||
|
||||
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
|
||||
if err != nil {
|
||||
if spotubeErr != nil {
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
|
||||
}
|
||||
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
reqBody := CobaltRequest{
|
||||
URL: videoURL,
|
||||
AudioFormat: audioFormat,
|
||||
AudioBitrate: audioBitrate,
|
||||
DownloadMode: "audio",
|
||||
FilenameStyle: "basic",
|
||||
DisableMetadata: true,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cobalt API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var cobaltResp CobaltResponse
|
||||
if err := json.Unmarshal(body, &cobaltResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
|
||||
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
|
||||
}
|
||||
|
||||
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
|
||||
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
|
||||
}
|
||||
|
||||
if cobaltResp.URL == "" {
|
||||
return nil, fmt.Errorf("no download URL in response")
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
|
||||
return &cobaltResp, nil
|
||||
}
|
||||
|
||||
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
|
||||
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
|
||||
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
|
||||
engines := []string{"v1"}
|
||||
if strings.EqualFold(audioFormat, "mp3") {
|
||||
engines = append(engines, "v3", "v2")
|
||||
}
|
||||
var lastErr error
|
||||
|
||||
for _, engine := range engines {
|
||||
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
|
||||
}
|
||||
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("no SpotubeDL engine available")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
|
||||
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
|
||||
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
|
||||
|
||||
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
|
||||
|
||||
req, err := http.NewRequest("GET", apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spotubedl request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
|
||||
}
|
||||
|
||||
downloadURL := strings.TrimSpace(result.URL)
|
||||
if downloadURL == "" {
|
||||
if result.Error != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
|
||||
}
|
||||
if result.Message != "" {
|
||||
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
|
||||
}
|
||||
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(downloadURL, "/") {
|
||||
downloadURL = spotubeBaseURL + downloadURL
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
|
||||
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
|
||||
}
|
||||
|
||||
filename := strings.TrimSpace(result.Filename)
|
||||
if filename == "" {
|
||||
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
|
||||
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
|
||||
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
|
||||
filename = decodedFilename
|
||||
} else {
|
||||
filename = queryFilename
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
|
||||
return &CobaltResponse{
|
||||
Status: "tunnel",
|
||||
URL: downloadURL,
|
||||
Filename: filename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(y.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
expectedSize := resp.ContentLength
|
||||
if expectedSize > 0 && itemID != "" {
|
||||
SetItemBytesTotal(itemID, expectedSize)
|
||||
}
|
||||
|
||||
out, err := openOutputForWrite(outputPath, outputFD)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create output file: %w", err)
|
||||
}
|
||||
|
||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||
|
||||
var written int64
|
||||
if itemID != "" {
|
||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||
written, err = io.Copy(progressWriter, resp.Body)
|
||||
} else {
|
||||
written, err = io.Copy(bufWriter, resp.Body)
|
||||
}
|
||||
|
||||
flushErr := bufWriter.Flush()
|
||||
closeErr := out.Close()
|
||||
|
||||
if err != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to flush buffer: %w", flushErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||
}
|
||||
|
||||
if expectedSize > 0 && written != expectedSize {
|
||||
cleanupOutputOnError(outputPath, outputFD)
|
||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Download completed: %d bytes written\n", written)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func BuildYouTubeSearchURL(trackName, artistName string) string {
|
||||
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
|
||||
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
|
||||
}
|
||||
|
||||
func BuildYouTubeWatchURL(videoID string) string {
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
func isYouTubeVideoID(s string) bool {
|
||||
if len(s) != 11 {
|
||||
return false
|
||||
}
|
||||
for _, c := range s {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func IsYouTubeURL(urlStr string) bool {
|
||||
lower := strings.ToLower(urlStr)
|
||||
return strings.Contains(lower, "youtube.com") ||
|
||||
strings.Contains(lower, "youtu.be") ||
|
||||
strings.Contains(lower, "music.youtube.com")
|
||||
}
|
||||
|
||||
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
|
||||
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
|
||||
func toYouTubeMusicURL(rawURL string) string {
|
||||
videoID, err := ExtractYouTubeVideoID(rawURL)
|
||||
if err != nil {
|
||||
return rawURL
|
||||
}
|
||||
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
|
||||
}
|
||||
|
||||
func ExtractYouTubeVideoID(urlStr string) (string, error) {
|
||||
if strings.Contains(urlStr, "youtu.be/") {
|
||||
parts := strings.Split(urlStr, "youtu.be/")
|
||||
if len(parts) >= 2 {
|
||||
videoID := strings.Split(parts[1], "?")[0]
|
||||
videoID = strings.Split(videoID, "&")[0]
|
||||
return strings.TrimSpace(videoID), nil
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if v := parsed.Query().Get("v"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
if strings.Contains(parsed.Path, "/embed/") {
|
||||
parts := strings.Split(parsed.Path, "/embed/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(parsed.Path, "/v/") {
|
||||
parts := strings.Split(parsed.Path, "/v/")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Split(parts[1], "/")[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not extract video ID from URL")
|
||||
}
|
||||
|
||||
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
|
||||
// to find a track by artist + title. It filters for tracks only (not videos,
|
||||
// albums, or playlists) and returns the YouTube Music watch URL for the first
|
||||
// matching track, or "" if nothing was found.
|
||||
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
|
||||
extManager := GetExtensionManager()
|
||||
searchProviders := extManager.GetSearchProviders()
|
||||
|
||||
// Find the ytmusic-spotiflac extension
|
||||
var ytProvider *ExtensionProviderWrapper
|
||||
for _, p := range searchProviders {
|
||||
if p.extension.ID == "ytmusic-spotiflac" {
|
||||
ytProvider = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if ytProvider == nil {
|
||||
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
|
||||
return ""
|
||||
}
|
||||
|
||||
query := strings.TrimSpace(artistName + " " + trackName)
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
|
||||
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
|
||||
"filter": "tracks",
|
||||
})
|
||||
if err != nil {
|
||||
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
// Find the first track result (item_type == "track" with a valid video ID)
|
||||
for _, track := range results {
|
||||
if track.ItemType != "" && track.ItemType != "track" {
|
||||
continue
|
||||
}
|
||||
videoID := strings.TrimSpace(track.ID)
|
||||
if videoID == "" {
|
||||
continue
|
||||
}
|
||||
if isYouTubeVideoID(videoID) {
|
||||
return BuildYouTubeWatchURL(videoID)
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
|
||||
return ""
|
||||
}
|
||||
|
||||
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
|
||||
downloader := NewYouTubeDownloader()
|
||||
|
||||
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
|
||||
|
||||
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
|
||||
var youtubeURL string
|
||||
var lookupErr error
|
||||
|
||||
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
|
||||
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
|
||||
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
|
||||
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
|
||||
}
|
||||
|
||||
// Try YT Music extension search first (if installed) - more accurate, tracks only
|
||||
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
|
||||
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
|
||||
if youtubeURL != "" {
|
||||
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try Spotify ID via SongLink
|
||||
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try Deezer ID via SongLink
|
||||
if youtubeURL == "" && req.DeezerID != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
|
||||
songlink := NewSongLinkClient()
|
||||
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
|
||||
if lookupErr != nil {
|
||||
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
|
||||
} else {
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Try ISRC via SongLink
|
||||
if youtubeURL == "" && req.ISRC != "" {
|
||||
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
|
||||
songlink := NewSongLinkClient()
|
||||
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
|
||||
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
|
||||
youtubeURL = availability.YouTubeURL
|
||||
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
|
||||
} else if isrcErr != nil {
|
||||
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cobalt requires direct video URLs, not search URLs
|
||||
if youtubeURL == "" {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
|
||||
|
||||
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
|
||||
if err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||
}
|
||||
|
||||
ext := ".mp3"
|
||||
if format == "opus" {
|
||||
ext = ".opus"
|
||||
}
|
||||
|
||||
// Some SpotubeDL engines may return a different output container than requested.
|
||||
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
|
||||
if cobaltResp != nil && cobaltResp.Filename != "" {
|
||||
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
|
||||
switch {
|
||||
case strings.HasSuffix(lowerName, ".mp3"):
|
||||
ext = ".mp3"
|
||||
format = "mp3"
|
||||
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
|
||||
ext = ".opus"
|
||||
format = "opus"
|
||||
}
|
||||
}
|
||||
|
||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
|
||||
"title": req.TrackName,
|
||||
"artist": req.ArtistName,
|
||||
"album": req.AlbumName,
|
||||
"track": req.TrackNumber,
|
||||
"year": extractYear(req.ReleaseDate),
|
||||
"date": req.ReleaseDate,
|
||||
"disc": req.DiscNumber,
|
||||
})
|
||||
filename = sanitizeFilename(filename) + ext
|
||||
|
||||
var outputPath string
|
||||
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
|
||||
if isSafOutput {
|
||||
outputPath = strings.TrimSpace(req.OutputPath)
|
||||
if outputPath == "" && isFDOutput(req.OutputFD) {
|
||||
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
|
||||
}
|
||||
} else {
|
||||
outputPath = req.OutputDir + "/" + filename
|
||||
}
|
||||
|
||||
GoLog("[YouTube] Downloading to: %s\n", outputPath)
|
||||
|
||||
var parallelResult *ParallelDownloadResult
|
||||
if req.EmbedLyrics || req.CoverURL != "" {
|
||||
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
|
||||
parallelResult = FetchCoverAndLyricsParallel(
|
||||
req.CoverURL,
|
||||
req.EmbedMaxQualityCover,
|
||||
req.SpotifyID,
|
||||
req.TrackName,
|
||||
req.ArtistName,
|
||||
req.EmbedLyrics,
|
||||
int64(req.DurationMS),
|
||||
)
|
||||
}
|
||||
|
||||
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
|
||||
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
lyricsLRC := ""
|
||||
var coverData []byte
|
||||
if parallelResult != nil {
|
||||
if parallelResult.LyricsLRC != "" {
|
||||
lyricsLRC = parallelResult.LyricsLRC
|
||||
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
|
||||
}
|
||||
if parallelResult.CoverData != nil {
|
||||
coverData = parallelResult.CoverData
|
||||
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
|
||||
}
|
||||
}
|
||||
|
||||
return YouTubeDownloadResult{
|
||||
FilePath: outputPath,
|
||||
Title: req.TrackName,
|
||||
Artist: req.ArtistName,
|
||||
Album: req.AlbumName,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ISRC: req.ISRC,
|
||||
Format: format,
|
||||
Bitrate: bitrate,
|
||||
LyricsLRC: lyricsLRC,
|
||||
CoverData: coverData,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package gobackend
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
|
||||
if format != "opus" {
|
||||
t.Fatalf("expected opus format, got %s", format)
|
||||
}
|
||||
if bitrate != 128 {
|
||||
t.Fatalf("expected 128 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityOpus128 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
|
||||
if format != "mp3" {
|
||||
t.Fatalf("expected mp3 format, got %s", format)
|
||||
}
|
||||
if bitrate != 256 {
|
||||
t.Fatalf("expected 256 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityMP3256 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
|
||||
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
|
||||
if opusBitrate != 320 {
|
||||
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
|
||||
}
|
||||
|
||||
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
|
||||
if mp3Bitrate != 128 {
|
||||
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
|
||||
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
|
||||
if format != "opus" {
|
||||
t.Fatalf("expected opus format, got %s", format)
|
||||
}
|
||||
if bitrate != 320 {
|
||||
t.Fatalf("expected 320 bitrate, got %d", bitrate)
|
||||
}
|
||||
if normalized != YouTubeQualityOpus320 {
|
||||
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@ import 'package:flutter/foundation.dart';
|
||||
/// App version and info constants
|
||||
/// 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.0';
|
||||
static const String buildNumber = '117';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
/// Shows "Internal" in debug builds, actual version in release.
|
||||
static String get displayVersion => kDebugMode ? 'Internal' : version;
|
||||
|
||||
static const String appName = 'SpotiFLAC';
|
||||
static const String appName = 'SpotiFLAC Mobile';
|
||||
static const String copyright = '© 2026 SpotiFLAC';
|
||||
|
||||
static const String mobileAuthor = 'zarzet';
|
||||
|
||||
+234
-18
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+20
-1336
File diff suppressed because it is too large
Load Diff
+11
-488
@@ -17,7 +17,7 @@
|
||||
"@navSettings": {
|
||||
"description": "Bottom navigation - Settings tab"
|
||||
},
|
||||
"navStore": "Repo",
|
||||
"navStore": "Store",
|
||||
"@navStore": {
|
||||
"description": "Bottom navigation - Extension store tab"
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"@homeTitle": {
|
||||
"description": "Home screen title"
|
||||
},
|
||||
"homeSubtitle": "Paste a supported URL or search by name",
|
||||
"homeSubtitle": "Paste a Spotify link or search by name",
|
||||
"@homeSubtitle": {
|
||||
"description": "Subtitle shown below search box"
|
||||
},
|
||||
@@ -89,14 +89,6 @@
|
||||
"@downloadFilenameFormat": {
|
||||
"description": "Setting for output filename pattern"
|
||||
},
|
||||
"downloadSingleFilenameFormat": "Single Filename Format",
|
||||
"@downloadSingleFilenameFormat": {
|
||||
"description": "Setting for output filename pattern for singles/EPs"
|
||||
},
|
||||
"downloadSingleFilenameFormatDescription": "Filename pattern for singles and EPs. Uses the same tags as the album format.",
|
||||
"@downloadSingleFilenameFormatDescription": {
|
||||
"description": "Subtitle description for single filename format setting"
|
||||
},
|
||||
"downloadFolderOrganization": "Folder Organization",
|
||||
"@downloadFolderOrganization": {
|
||||
"description": "Setting for folder structure"
|
||||
@@ -158,14 +150,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"optionsDefaultSearchTab": "Default Search Tab",
|
||||
"@optionsDefaultSearchTab": {
|
||||
"description": "Title for the preferred default search tab setting"
|
||||
},
|
||||
"optionsDefaultSearchTabSubtitle": "Choose which tab opens first for new search results.",
|
||||
"@optionsDefaultSearchTabSubtitle": {
|
||||
"description": "Subtitle for the preferred default search tab setting"
|
||||
},
|
||||
"optionsSwitchBack": "Tap Deezer or Spotify to switch back from extension",
|
||||
"@optionsSwitchBack": {
|
||||
"description": "Hint to switch back to built-in providers"
|
||||
@@ -206,42 +190,6 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsReplayGain": "ReplayGain",
|
||||
"@optionsReplayGain": {
|
||||
"description": "Title for ReplayGain setting toggle"
|
||||
},
|
||||
"optionsReplayGainSubtitleOn": "Scan loudness and embed ReplayGain tags (EBU R128)",
|
||||
"@optionsReplayGainSubtitleOn": {
|
||||
"description": "Subtitle when ReplayGain is enabled"
|
||||
},
|
||||
"optionsReplayGainSubtitleOff": "Disabled: no loudness normalization tags",
|
||||
"@optionsReplayGainSubtitleOff": {
|
||||
"description": "Subtitle when ReplayGain is disabled"
|
||||
},
|
||||
"optionsArtistTagMode": "Artist Tag Mode",
|
||||
"@optionsArtistTagMode": {
|
||||
"description": "Setting title for how artist metadata is written into files"
|
||||
},
|
||||
"optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.",
|
||||
"@optionsArtistTagModeDescription": {
|
||||
"description": "Bottom-sheet description for artist tag mode setting"
|
||||
},
|
||||
"optionsArtistTagModeJoined": "Single joined value",
|
||||
"@optionsArtistTagModeJoined": {
|
||||
"description": "Artist tag mode option that joins multiple artists into one value"
|
||||
},
|
||||
"optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.",
|
||||
"@optionsArtistTagModeJoinedSubtitle": {
|
||||
"description": "Subtitle for joined artist tag mode"
|
||||
},
|
||||
"optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus",
|
||||
"@optionsArtistTagModeSplitVorbis": {
|
||||
"description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats"
|
||||
},
|
||||
"optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.",
|
||||
"@optionsArtistTagModeSplitVorbisSubtitle": {
|
||||
"description": "Subtitle for split Vorbis artist tag mode"
|
||||
},
|
||||
"optionsConcurrentDownloads": "Concurrent Downloads",
|
||||
"@optionsConcurrentDownloads": {
|
||||
"description": "Number of parallel downloads"
|
||||
@@ -263,11 +211,11 @@
|
||||
"@optionsConcurrentWarning": {
|
||||
"description": "Warning about rate limits"
|
||||
},
|
||||
"optionsExtensionStore": "Extension Repo",
|
||||
"optionsExtensionStore": "Extension Store",
|
||||
"@optionsExtensionStore": {
|
||||
"description": "Show/hide store tab"
|
||||
},
|
||||
"optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
|
||||
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
|
||||
"@optionsExtensionStoreSubtitle": {
|
||||
"description": "Subtitle for extension store toggle"
|
||||
},
|
||||
@@ -370,7 +318,7 @@
|
||||
"@extensionsUninstall": {
|
||||
"description": "Uninstall extension button"
|
||||
},
|
||||
"storeTitle": "Extension Repo",
|
||||
"storeTitle": "Extension Store",
|
||||
"@storeTitle": {
|
||||
"description": "Store screen title"
|
||||
},
|
||||
@@ -430,10 +378,6 @@
|
||||
"@aboutPCSource": {
|
||||
"description": "Link to PC GitHub repo"
|
||||
},
|
||||
"aboutKeepAndroidOpen": "Keep Android Open",
|
||||
"@aboutKeepAndroidOpen": {
|
||||
"description": "Link to Keep Android Open campaign website"
|
||||
},
|
||||
"aboutReportIssue": "Report an issue",
|
||||
"@aboutReportIssue": {
|
||||
"description": "Link to report bugs"
|
||||
@@ -1215,18 +1159,6 @@
|
||||
"@providerPriorityInfo": {
|
||||
"description": "Info tip about fallback behavior"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsTitle": "Extension Fallback",
|
||||
"@providerPriorityFallbackExtensionsTitle": {
|
||||
"description": "Section title for choosing which download extensions can be used as fallback providers"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsDescription": "Choose which installed download extensions can be used during automatic fallback. Built-in providers still follow the priority order above.",
|
||||
"@providerPriorityFallbackExtensionsDescription": {
|
||||
"description": "Section description for extension fallback selection"
|
||||
},
|
||||
"providerPriorityFallbackExtensionsHint": "Only enabled extensions with download-provider capability are listed here.",
|
||||
"@providerPriorityFallbackExtensionsHint": {
|
||||
"description": "Hint below the extension fallback selection list"
|
||||
},
|
||||
"providerBuiltIn": "Built-in",
|
||||
"@providerBuiltIn": {
|
||||
"description": "Label for built-in providers (Tidal/Qobuz)"
|
||||
@@ -1587,14 +1519,6 @@
|
||||
"@trackLyricsNotAvailable": {
|
||||
"description": "Message when lyrics not found"
|
||||
},
|
||||
"trackLyricsNotInFile": "No lyrics found in this file",
|
||||
"@trackLyricsNotInFile": {
|
||||
"description": "Message when no embedded lyrics in audio file"
|
||||
},
|
||||
"trackFetchOnlineLyrics": "Fetch from Online",
|
||||
"@trackFetchOnlineLyrics": {
|
||||
"description": "Action - fetch lyrics from online providers"
|
||||
},
|
||||
"trackLyricsTimeout": "Request timed out. Try again later.",
|
||||
"@trackLyricsTimeout": {
|
||||
"description": "Message when lyrics request times out"
|
||||
@@ -1730,7 +1654,7 @@
|
||||
"@storeNewRepoUrlLabel": {
|
||||
"description": "Label for the new repository URL field inside the dialog"
|
||||
},
|
||||
"storeLoadError": "Failed to load repository",
|
||||
"storeLoadError": "Failed to load store",
|
||||
"@storeLoadError": {
|
||||
"description": "Error heading when the store cannot be loaded"
|
||||
},
|
||||
@@ -1881,14 +1805,6 @@
|
||||
"@extensionsDownloadPrioritySubtitle": {
|
||||
"description": "Subtitle for download priority"
|
||||
},
|
||||
"extensionsFallbackTitle": "Fallback Extensions",
|
||||
"@extensionsFallbackTitle": {
|
||||
"description": "Setting and page title for choosing which download extensions can be used during fallback"
|
||||
},
|
||||
"extensionsFallbackSubtitle": "Choose which installed download extensions can be used as fallback",
|
||||
"@extensionsFallbackSubtitle": {
|
||||
"description": "Subtitle for download fallback extensions menu"
|
||||
},
|
||||
"extensionsNoDownloadProvider": "No extensions with download provider",
|
||||
"@extensionsNoDownloadProvider": {
|
||||
"description": "Empty state - no download providers"
|
||||
@@ -2483,15 +2399,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryFilesUnit": "{count, plural, =1{file} other{files}}",
|
||||
"@libraryFilesUnit": {
|
||||
"description": "Unit label for files count during library scanning",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"libraryLastScanned": "Last scanned: {time}",
|
||||
"@libraryLastScanned": {
|
||||
"description": "Last scan time display",
|
||||
@@ -2509,10 +2416,6 @@
|
||||
"@libraryScanning": {
|
||||
"description": "Status during scan"
|
||||
},
|
||||
"libraryScanFinalizing": "Finalizing library...",
|
||||
"@libraryScanFinalizing": {
|
||||
"description": "Status shown after file scanning finishes but library persistence is still running"
|
||||
},
|
||||
"libraryScanProgress": "{progress}% of {total} files",
|
||||
"@libraryScanProgress": {
|
||||
"description": "Scan progress display",
|
||||
@@ -2610,30 +2513,6 @@
|
||||
"@libraryFilterFormat": {
|
||||
"description": "Filter section - file format"
|
||||
},
|
||||
"libraryFilterMetadata": "Metadata",
|
||||
"@libraryFilterMetadata": {
|
||||
"description": "Filter section - metadata completeness"
|
||||
},
|
||||
"libraryFilterMetadataComplete": "Complete metadata",
|
||||
"@libraryFilterMetadataComplete": {
|
||||
"description": "Filter option - items with complete metadata"
|
||||
},
|
||||
"libraryFilterMetadataMissingAny": "Missing any metadata",
|
||||
"@libraryFilterMetadataMissingAny": {
|
||||
"description": "Filter option - items missing any tracked metadata field"
|
||||
},
|
||||
"libraryFilterMetadataMissingYear": "Missing year",
|
||||
"@libraryFilterMetadataMissingYear": {
|
||||
"description": "Filter option - items missing release year/date"
|
||||
},
|
||||
"libraryFilterMetadataMissingGenre": "Missing genre",
|
||||
"@libraryFilterMetadataMissingGenre": {
|
||||
"description": "Filter option - items missing genre"
|
||||
},
|
||||
"libraryFilterMetadataMissingAlbumArtist": "Missing album artist",
|
||||
"@libraryFilterMetadataMissingAlbumArtist": {
|
||||
"description": "Filter option - items missing album artist"
|
||||
},
|
||||
"libraryFilterSort": "Sort",
|
||||
"@libraryFilterSort": {
|
||||
"description": "Filter section - sort order"
|
||||
@@ -2646,22 +2525,6 @@
|
||||
"@libraryFilterSortOldest": {
|
||||
"description": "Sort option - oldest first"
|
||||
},
|
||||
"libraryFilterSortAlbumAsc": "Album (A-Z)",
|
||||
"@libraryFilterSortAlbumAsc": {
|
||||
"description": "Sort option - album ascending"
|
||||
},
|
||||
"libraryFilterSortAlbumDesc": "Album (Z-A)",
|
||||
"@libraryFilterSortAlbumDesc": {
|
||||
"description": "Sort option - album descending"
|
||||
},
|
||||
"libraryFilterSortGenreAsc": "Genre (A-Z)",
|
||||
"@libraryFilterSortGenreAsc": {
|
||||
"description": "Sort option - genre ascending"
|
||||
},
|
||||
"libraryFilterSortGenreDesc": "Genre (Z-A)",
|
||||
"@libraryFilterSortGenreDesc": {
|
||||
"description": "Sort option - genre descending"
|
||||
},
|
||||
"timeJustNow": "Just now",
|
||||
"@timeJustNow": {
|
||||
"description": "Relative time - less than a minute ago"
|
||||
@@ -2748,7 +2611,7 @@
|
||||
"@tutorialExtensionsDesc": {
|
||||
"description": "Tutorial extensions page description"
|
||||
},
|
||||
"tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
|
||||
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
|
||||
"@tutorialExtensionsTip1": {
|
||||
"description": "Tutorial extensions tip 1"
|
||||
},
|
||||
@@ -3014,38 +2877,6 @@
|
||||
"@trackReEnrichOnlineSubtitle": {
|
||||
"description": "Subtitle for re-enrich metadata action for local items"
|
||||
},
|
||||
"trackReEnrichFieldsTitle": "Fields to update",
|
||||
"@trackReEnrichFieldsTitle": {
|
||||
"description": "Section title for field selection in re-enrich dialog"
|
||||
},
|
||||
"trackReEnrichFieldCover": "Cover Art",
|
||||
"@trackReEnrichFieldCover": {
|
||||
"description": "Checkbox label for cover art field in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldLyrics": "Lyrics",
|
||||
"@trackReEnrichFieldLyrics": {
|
||||
"description": "Checkbox label for lyrics field in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldBasicTags": "Album, Album Artist",
|
||||
"@trackReEnrichFieldBasicTags": {
|
||||
"description": "Checkbox label for basic tags in re-enrich (title/artist are never overwritten)"
|
||||
},
|
||||
"trackReEnrichFieldTrackInfo": "Track & Disc Number",
|
||||
"@trackReEnrichFieldTrackInfo": {
|
||||
"description": "Checkbox label for track info in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldReleaseInfo": "Date & ISRC",
|
||||
"@trackReEnrichFieldReleaseInfo": {
|
||||
"description": "Checkbox label for release info in re-enrich"
|
||||
},
|
||||
"trackReEnrichFieldExtra": "Genre, Label, Copyright",
|
||||
"@trackReEnrichFieldExtra": {
|
||||
"description": "Checkbox label for extra metadata in re-enrich"
|
||||
},
|
||||
"trackReEnrichSelectAll": "Select All",
|
||||
"@trackReEnrichSelectAll": {
|
||||
"description": "Select all fields checkbox in re-enrich"
|
||||
},
|
||||
"trackEditMetadata": "Edit Metadata",
|
||||
"@trackEditMetadata": {
|
||||
"description": "Menu action - edit embedded metadata"
|
||||
@@ -3643,6 +3474,10 @@
|
||||
"@lyricsProvidersDiscardContent": {
|
||||
"description": "Body text of the discard-changes dialog on lyrics provider page"
|
||||
},
|
||||
"lyricsProviderSpotifyApiDesc": "Spotify-sourced synced lyrics via community API",
|
||||
"@lyricsProviderSpotifyApiDesc": {
|
||||
"description": "Description for Spotify Lyrics API provider"
|
||||
},
|
||||
"lyricsProviderLrclibDesc": "Open-source synced lyrics database",
|
||||
"@lyricsProviderLrclibDesc": {
|
||||
"description": "Description for LRCLIB provider"
|
||||
@@ -4228,317 +4063,5 @@
|
||||
"audioAnalysisSamples": "Samples",
|
||||
"@audioAnalysisSamples": {
|
||||
"description": "Total samples metric label"
|
||||
},
|
||||
|
||||
"extensionsSearchWith": "Search with {providerName}",
|
||||
"@extensionsSearchWith": {
|
||||
"description": "Extensions page - subtitle for built-in search provider option",
|
||||
"placeholders": {
|
||||
"providerName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionsHomeFeedProvider": "Home Feed Provider",
|
||||
"@extensionsHomeFeedProvider": {
|
||||
"description": "Extensions page - label for home feed provider selector"
|
||||
},
|
||||
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
|
||||
"@extensionsHomeFeedDescription": {
|
||||
"description": "Extensions page - description for home feed provider picker"
|
||||
},
|
||||
"extensionsHomeFeedAuto": "Auto",
|
||||
"@extensionsHomeFeedAuto": {
|
||||
"description": "Extensions page - home feed provider option: auto"
|
||||
},
|
||||
"extensionsHomeFeedAutoSubtitle": "Automatically select the best available",
|
||||
"@extensionsHomeFeedAutoSubtitle": {
|
||||
"description": "Extensions page - subtitle for auto home feed option"
|
||||
},
|
||||
"extensionsHomeFeedUse": "Use {extensionName} home feed",
|
||||
"@extensionsHomeFeedUse": {
|
||||
"description": "Extensions page - subtitle for a specific extension home feed option",
|
||||
"placeholders": {
|
||||
"extensionName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensionsNoHomeFeedExtensions": "No extensions with home feed",
|
||||
"@extensionsNoHomeFeedExtensions": {
|
||||
"description": "Extensions page - shown when no installed extension has home feed"
|
||||
},
|
||||
|
||||
"sortAlphaAsc": "A-Z",
|
||||
"@sortAlphaAsc": {
|
||||
"description": "Sort option - alphabetical ascending"
|
||||
},
|
||||
"sortAlphaDesc": "Z-A",
|
||||
"@sortAlphaDesc": {
|
||||
"description": "Sort option - alphabetical descending"
|
||||
},
|
||||
"cancelDownloadTitle": "Cancel download?",
|
||||
"@cancelDownloadTitle": {
|
||||
"description": "Dialog title when confirming cancellation of an active download"
|
||||
},
|
||||
"cancelDownloadContent": "This will cancel the active download for \"{trackName}\".",
|
||||
"@cancelDownloadContent": {
|
||||
"description": "Dialog body when confirming cancellation of an active download",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"cancelDownloadKeep": "Keep",
|
||||
"@cancelDownloadKeep": {
|
||||
"description": "Dialog button - keep the active download (do not cancel)"
|
||||
},
|
||||
|
||||
"metadataSaveFailedFfmpeg": "Failed to save metadata via FFmpeg",
|
||||
"@metadataSaveFailedFfmpeg": {
|
||||
"description": "Snackbar error when FFmpeg fails to write metadata"
|
||||
},
|
||||
"metadataSaveFailedStorage": "Failed to write metadata back to storage",
|
||||
"@metadataSaveFailedStorage": {
|
||||
"description": "Snackbar error when writing metadata file back to storage fails"
|
||||
},
|
||||
|
||||
"snackbarFolderPickerFailed": "Failed to open folder picker: {error}",
|
||||
"@snackbarFolderPickerFailed": {
|
||||
"description": "Snackbar shown when folder picker fails to open",
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"errorLoadAlbum": "Failed to load album",
|
||||
"@errorLoadAlbum": {
|
||||
"description": "Error state shown when album fails to load"
|
||||
},
|
||||
"errorLoadPlaylist": "Failed to load playlist",
|
||||
"@errorLoadPlaylist": {
|
||||
"description": "Error state shown when playlist fails to load"
|
||||
},
|
||||
"errorLoadArtist": "Failed to load artist",
|
||||
"@errorLoadArtist": {
|
||||
"description": "Error state shown when artist fails to load"
|
||||
},
|
||||
|
||||
"notifChannelDownloadName": "Download Progress",
|
||||
"@notifChannelDownloadName": {
|
||||
"description": "Android notification channel name for download progress"
|
||||
},
|
||||
"notifChannelDownloadDesc": "Shows download progress for tracks",
|
||||
"@notifChannelDownloadDesc": {
|
||||
"description": "Android notification channel description for download progress"
|
||||
},
|
||||
"notifChannelLibraryScanName": "Library Scan",
|
||||
"@notifChannelLibraryScanName": {
|
||||
"description": "Android notification channel name for library scan"
|
||||
},
|
||||
"notifChannelLibraryScanDesc": "Shows local library scan progress",
|
||||
"@notifChannelLibraryScanDesc": {
|
||||
"description": "Android notification channel description for library scan"
|
||||
},
|
||||
"notifDownloadingTrack": "Downloading {trackName}",
|
||||
"@notifDownloadingTrack": {
|
||||
"description": "Notification title while downloading a track",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifFinalizingTrack": "Finalizing {trackName}",
|
||||
"@notifFinalizingTrack": {
|
||||
"description": "Notification title while finalizing (embedding metadata) a track",
|
||||
"placeholders": {
|
||||
"trackName": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifEmbeddingMetadata": "Embedding metadata...",
|
||||
"@notifEmbeddingMetadata": {
|
||||
"description": "Notification body while embedding metadata into a downloaded track"
|
||||
},
|
||||
"notifAlreadyInLibraryCount": "Already in Library ({completed}/{total})",
|
||||
"@notifAlreadyInLibraryCount": {
|
||||
"description": "Notification title when track is already in library, with count",
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifAlreadyInLibrary": "Already in Library",
|
||||
"@notifAlreadyInLibrary": {
|
||||
"description": "Notification title when track is already in library"
|
||||
},
|
||||
"notifDownloadCompleteCount": "Download Complete ({completed}/{total})",
|
||||
"@notifDownloadCompleteCount": {
|
||||
"description": "Notification title when download is complete, with count",
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifDownloadComplete": "Download Complete",
|
||||
"@notifDownloadComplete": {
|
||||
"description": "Notification title when a single download is complete"
|
||||
},
|
||||
"notifDownloadsFinished": "Downloads Finished ({completed} done, {failed} failed)",
|
||||
"@notifDownloadsFinished": {
|
||||
"description": "Notification title when queue finishes with some failures",
|
||||
"placeholders": {
|
||||
"completed": {
|
||||
"type": "int"
|
||||
},
|
||||
"failed": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifAllDownloadsComplete": "All Downloads Complete",
|
||||
"@notifAllDownloadsComplete": {
|
||||
"description": "Notification title when all downloads finish successfully"
|
||||
},
|
||||
"notifTracksDownloadedSuccess": "{count} tracks downloaded successfully",
|
||||
"@notifTracksDownloadedSuccess": {
|
||||
"description": "Notification body for queue complete - how many tracks were downloaded",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifScanningLibrary": "Scanning local library",
|
||||
"@notifScanningLibrary": {
|
||||
"description": "Notification title while scanning local library"
|
||||
},
|
||||
"notifLibraryScanProgressWithTotal": "{scanned}/{total} files • {percentage}%",
|
||||
"@notifLibraryScanProgressWithTotal": {
|
||||
"description": "Notification body for library scan progress when total is known",
|
||||
"placeholders": {
|
||||
"scanned": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"percentage": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanProgressNoTotal": "{scanned} files scanned • {percentage}%",
|
||||
"@notifLibraryScanProgressNoTotal": {
|
||||
"description": "Notification body for library scan progress when total is unknown",
|
||||
"placeholders": {
|
||||
"scanned": {
|
||||
"type": "int"
|
||||
},
|
||||
"percentage": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanComplete": "Library scan complete",
|
||||
"@notifLibraryScanComplete": {
|
||||
"description": "Notification title when library scan finishes"
|
||||
},
|
||||
"notifLibraryScanCompleteBody": "{count} tracks indexed",
|
||||
"@notifLibraryScanCompleteBody": {
|
||||
"description": "Notification body for library scan complete - number of indexed tracks",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanExcluded": "{count} excluded",
|
||||
"@notifLibraryScanExcluded": {
|
||||
"description": "Library scan complete suffix - excluded track count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanErrors": "{count} errors",
|
||||
"@notifLibraryScanErrors": {
|
||||
"description": "Library scan complete suffix - error count",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifLibraryScanFailed": "Library scan failed",
|
||||
"@notifLibraryScanFailed": {
|
||||
"description": "Notification title when library scan fails"
|
||||
},
|
||||
"notifLibraryScanCancelled": "Library scan cancelled",
|
||||
"@notifLibraryScanCancelled": {
|
||||
"description": "Notification title when library scan is cancelled by the user"
|
||||
},
|
||||
"notifLibraryScanStopped": "Scan stopped before completion.",
|
||||
"@notifLibraryScanStopped": {
|
||||
"description": "Notification body when library scan is cancelled"
|
||||
},
|
||||
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
|
||||
"@notifDownloadingUpdate": {
|
||||
"description": "Notification title while downloading an app update",
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifUpdateProgress": "{received} / {total} MB • {percentage}%",
|
||||
"@notifUpdateProgress": {
|
||||
"description": "Notification body showing update download progress",
|
||||
"placeholders": {
|
||||
"received": {
|
||||
"type": "String"
|
||||
},
|
||||
"total": {
|
||||
"type": "String"
|
||||
},
|
||||
"percentage": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifUpdateReady": "Update Ready",
|
||||
"@notifUpdateReady": {
|
||||
"description": "Notification title when app update download is complete"
|
||||
},
|
||||
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
|
||||
"@notifUpdateReadyBody": {
|
||||
"description": "Notification body when app update is ready to install",
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifUpdateFailed": "Update Failed",
|
||||
"@notifUpdateFailed": {
|
||||
"description": "Notification title when app update download fails"
|
||||
},
|
||||
"notifUpdateFailedBody": "Could not download update. Try again later.",
|
||||
"@notifUpdateFailedBody": {
|
||||
"description": "Notification body when app update download fails"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
+33
-1349
File diff suppressed because it is too large
Load Diff
+9
-1325
File diff suppressed because it is too large
Load Diff
+36
-1279
File diff suppressed because it is too large
Load Diff
+9
-1325
File diff suppressed because it is too large
Load Diff
+9
-1325
File diff suppressed because it is too large
Load Diff
+9
-1325
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
+20
-1336
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+10
-1326
File diff suppressed because it is too large
Load Diff
+10
-1326
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
|
||||
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
|
||||
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
|
||||
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
|
||||
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
|
||||
autoExportFailedDownloads:
|
||||
json['autoExportFailedDownloads'] as bool? ?? false,
|
||||
@@ -125,8 +123,6 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'locale': instance.locale,
|
||||
'lyricsMode': instance.lyricsMode,
|
||||
'tidalHighFormat': instance.tidalHighFormat,
|
||||
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
|
||||
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
|
||||
'useAllFilesAccess': instance.useAllFilesAccess,
|
||||
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
|
||||
'downloadNetworkMode': instance.downloadNetworkMode,
|
||||
|
||||
@@ -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<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
unawaited(
|
||||
Future<void>.delayed(_startupMaintenanceDelay, () async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
await _repairMissingSafEntries(
|
||||
initialItems,
|
||||
maxItems: _safRepairMaxPerLaunch,
|
||||
prefs: prefs,
|
||||
);
|
||||
await Future<void>.delayed(_startupMaintenanceStepGap);
|
||||
}
|
||||
|
||||
await cleanupOrphanedDownloads();
|
||||
await _cleanupOrphanedDownloadsIncremental(
|
||||
maxItems: _orphanCleanupMaxPerLaunch,
|
||||
prefs: prefs,
|
||||
);
|
||||
await Future<void>.delayed(_startupMaintenanceStepGap);
|
||||
|
||||
final currentItems = state.items;
|
||||
if (currentItems.isNotEmpty) {
|
||||
await _backfillAudioMetadata(
|
||||
currentItems,
|
||||
maxItems: _audioMetadataBackfillMaxPerLaunch,
|
||||
prefs: prefs,
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
@@ -344,6 +360,30 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
);
|
||||
}
|
||||
|
||||
int _readStartupCursor(SharedPreferences prefs, String key, int totalCount) {
|
||||
if (totalCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
final cursor = prefs.getInt(key) ?? 0;
|
||||
if (cursor < 0 || cursor >= totalCount) {
|
||||
return 0;
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
Future<void> _writeStartupCursor(
|
||||
SharedPreferences prefs,
|
||||
String key,
|
||||
int nextCursor,
|
||||
int totalCount,
|
||||
) async {
|
||||
if (totalCount <= 0 || nextCursor <= 0 || nextCursor >= totalCount) {
|
||||
await prefs.remove(key);
|
||||
return;
|
||||
}
|
||||
await prefs.setInt(key, nextCursor);
|
||||
}
|
||||
|
||||
String _fileNameFromUri(String uri) {
|
||||
try {
|
||||
final parsed = Uri.parse(uri);
|
||||
@@ -357,6 +397,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
Future<void> _repairMissingSafEntries(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required int maxItems,
|
||||
required SharedPreferences prefs,
|
||||
}) async {
|
||||
if (_isSafRepairInProgress || items.isEmpty) {
|
||||
return;
|
||||
@@ -378,22 +419,40 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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 = <Map<String, dynamic>>[];
|
||||
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<DownloadHistoryState> {
|
||||
updatedItems[i] = verified;
|
||||
changed = true;
|
||||
verifiedCount++;
|
||||
await _db.upsert(verified.toJson());
|
||||
persistedUpdates.add(verified.toJson());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -445,7 +504,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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');
|
||||
}
|
||||
@@ -456,11 +515,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
|
||||
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<DownloadHistoryState> {
|
||||
Future<void> _backfillAudioMetadata(
|
||||
List<DownloadHistoryItem> items, {
|
||||
required int maxItems,
|
||||
required SharedPreferences prefs,
|
||||
}) async {
|
||||
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
|
||||
return;
|
||||
@@ -563,15 +630,40 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
_isAudioMetadataBackfillInProgress = true;
|
||||
|
||||
try {
|
||||
final candidateIndexes = <int>[];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (_shouldBackfillAudioMetadata(items[i])) {
|
||||
candidateIndexes.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateIndexes.isEmpty) {
|
||||
await prefs.remove(_startupAudioCursorKey);
|
||||
return;
|
||||
}
|
||||
|
||||
final startCursor = _readStartupCursor(
|
||||
prefs,
|
||||
_startupAudioCursorKey,
|
||||
candidateIndexes.length,
|
||||
);
|
||||
final endCursor = (startCursor + maxItems).clamp(
|
||||
0,
|
||||
candidateIndexes.length,
|
||||
);
|
||||
final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor);
|
||||
|
||||
if (selectedIndexes.isEmpty) {
|
||||
await prefs.remove(_startupAudioCursorKey);
|
||||
return;
|
||||
}
|
||||
|
||||
List<DownloadHistoryItem>? updatedItems;
|
||||
final persistedUpdates = <Map<String, dynamic>>[];
|
||||
var refreshedCount = 0;
|
||||
|
||||
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<DownloadHistoryState> {
|
||||
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',
|
||||
@@ -768,9 +874,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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<DownloadHistoryState> {
|
||||
'.aac',
|
||||
];
|
||||
|
||||
/// When the original file is missing, check whether a sibling with a
|
||||
/// different audio extension exists (e.g. the user converted .flac → .opus).
|
||||
/// Returns the path of the first match found, or `null` if none exist.
|
||||
Future<String?> _findConvertedSibling(String originalPath) async {
|
||||
// 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<DownloadHistoryState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<int> cleanupOrphanedDownloads() async {
|
||||
_historyLog.i('Starting orphaned downloads cleanup...');
|
||||
|
||||
final entries = await _db.getAllEntriesWithPaths();
|
||||
Future<
|
||||
({
|
||||
List<String> orphanedIds,
|
||||
Map<String, String> replacementPaths,
|
||||
Map<String, String> pathById,
|
||||
})
|
||||
>
|
||||
_inspectOrphanedEntries(List<Map<String, dynamic>> entries) async {
|
||||
final orphanedIds = <String>[];
|
||||
final replacementPaths = <String, String>{};
|
||||
final pathById = <String, String>{};
|
||||
const checkChunkSize = 16;
|
||||
|
||||
@@ -824,14 +928,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
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,21 +955,127 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedIds.isEmpty) {
|
||||
return (
|
||||
orphanedIds: orphanedIds,
|
||||
replacementPaths: replacementPaths,
|
||||
pathById: pathById,
|
||||
);
|
||||
}
|
||||
|
||||
void _applyHistoryPathAndDeletionChanges({
|
||||
required List<String> deletedIds,
|
||||
required Map<String, String> replacementPaths,
|
||||
}) {
|
||||
if (deletedIds.isEmpty && replacementPaths.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final deletedSet = deletedIds.toSet();
|
||||
final updatedItems = <DownloadHistoryItem>[];
|
||||
for (final item in state.items) {
|
||||
if (deletedSet.contains(item.id)) {
|
||||
continue;
|
||||
}
|
||||
final replacementPath = replacementPaths[item.id];
|
||||
if (replacementPath != null && replacementPath != item.filePath) {
|
||||
updatedItems.add(item.copyWith(filePath: replacementPath));
|
||||
} else {
|
||||
updatedItems.add(item);
|
||||
}
|
||||
}
|
||||
state = state.copyWith(items: updatedItems);
|
||||
}
|
||||
|
||||
Future<int> _cleanupOrphanedDownloadsIncremental({
|
||||
required int maxItems,
|
||||
required SharedPreferences prefs,
|
||||
}) async {
|
||||
final cursor = prefs.getInt(_startupOrphanCursorKey) ?? 0;
|
||||
final safeCursor = cursor < 0 ? 0 : cursor;
|
||||
final entries = await _db.getEntriesWithPathsPage(
|
||||
limit: maxItems,
|
||||
offset: safeCursor,
|
||||
);
|
||||
if (entries.isEmpty) {
|
||||
await prefs.remove(_startupOrphanCursorKey);
|
||||
return 0;
|
||||
}
|
||||
|
||||
final result = await _inspectOrphanedEntries(entries);
|
||||
for (final replacement in result.replacementPaths.entries) {
|
||||
await _db.updateFilePath(replacement.key, replacement.value);
|
||||
}
|
||||
|
||||
final deletedCount = result.orphanedIds.isEmpty
|
||||
? 0
|
||||
: await _db.deleteByIds(result.orphanedIds);
|
||||
|
||||
_applyHistoryPathAndDeletionChanges(
|
||||
deletedIds: result.orphanedIds,
|
||||
replacementPaths: result.replacementPaths,
|
||||
);
|
||||
|
||||
if (entries.length < maxItems) {
|
||||
await prefs.remove(_startupOrphanCursorKey);
|
||||
} else {
|
||||
final nextCursor =
|
||||
safeCursor + entries.length - result.orphanedIds.length;
|
||||
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
|
||||
}
|
||||
|
||||
if (deletedCount > 0 || result.replacementPaths.isNotEmpty) {
|
||||
_historyLog.i(
|
||||
'Startup orphan cleanup pass: removed=$deletedCount, repaired=${result.replacementPaths.length}, checked=${entries.length}',
|
||||
);
|
||||
}
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
Future<int> cleanupOrphanedDownloads() async {
|
||||
_historyLog.i('Starting orphaned downloads cleanup...');
|
||||
final orphanedIds = <String>[];
|
||||
final replacementPaths = <String, String>{};
|
||||
const pageSize = 256;
|
||||
var offset = 0;
|
||||
|
||||
while (true) {
|
||||
final entries = await _db.getEntriesWithPathsPage(
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
);
|
||||
if (entries.isEmpty) {
|
||||
break;
|
||||
}
|
||||
|
||||
final result = await _inspectOrphanedEntries(entries);
|
||||
orphanedIds.addAll(result.orphanedIds);
|
||||
replacementPaths.addAll(result.replacementPaths);
|
||||
|
||||
if (entries.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
offset += entries.length - result.orphanedIds.length;
|
||||
}
|
||||
|
||||
for (final replacement in replacementPaths.entries) {
|
||||
await _db.updateFilePath(replacement.key, replacement.value);
|
||||
}
|
||||
|
||||
if (orphanedIds.isEmpty && replacementPaths.isEmpty) {
|
||||
_historyLog.i('No orphaned entries found');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1700,6 +1908,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
if (albumFolderStructure == 'artist_album_flat') {
|
||||
if (isSingle) {
|
||||
final artistPath = '$baseDir${Platform.pathSeparator}$artistName';
|
||||
await _ensureDirExists(artistPath, label: 'Artist folder');
|
||||
return artistPath;
|
||||
} else {
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
final albumPath =
|
||||
'$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
await _ensureDirExists(albumPath, label: 'Artist Album folder');
|
||||
return albumPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSingle) {
|
||||
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
|
||||
await _ensureDirExists(singlesPath, label: 'Singles folder');
|
||||
@@ -1920,15 +2142,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -2206,6 +2425,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_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 +2718,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +2730,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||
}
|
||||
|
||||
// Deezer CDN upgrade (1000x1000 → 1800x1800)
|
||||
if (result.contains('cdn-images.dzcdn.net')) {
|
||||
final upgraded = result.replaceFirst(
|
||||
_deezerSizeRegex,
|
||||
@@ -3168,7 +3410,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Future<void> _processQueue() async {
|
||||
if (state.isProcessing) return;
|
||||
|
||||
// Check network connectivity before starting
|
||||
final settings = ref.read(settingsProvider);
|
||||
updateSettings(settings);
|
||||
final isSafMode = _isSafMode(settings);
|
||||
@@ -3228,7 +3469,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +3490,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_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 +3498,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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(
|
||||
@@ -3402,8 +3637,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -3555,28 +3788,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
|
||||
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 +3895,101 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
||||
// For tidal:/qobuz: tracks without ISRC, resolve ISRC from provider
|
||||
// API directly (faster than SongLink and avoids rate limits).
|
||||
if (deezerTrackId == null &&
|
||||
(trackToDownload.isrc == null ||
|
||||
trackToDownload.isrc!.isEmpty ||
|
||||
!_isValidISRC(trackToDownload.isrc!)) &&
|
||||
(trackToDownload.id.startsWith('tidal:') ||
|
||||
trackToDownload.id.startsWith('qobuz:'))) {
|
||||
try {
|
||||
final colonIdx = trackToDownload.id.indexOf(':');
|
||||
final provider = trackToDownload.id.substring(0, colonIdx);
|
||||
final providerTrackId = trackToDownload.id.substring(colonIdx + 1);
|
||||
|
||||
_log.d('No ISRC, fetching from $provider API: $providerTrackId');
|
||||
final providerData = provider == 'tidal'
|
||||
? await PlatformBridge.getTidalMetadata('track', providerTrackId)
|
||||
: await PlatformBridge.getQobuzMetadata('track', providerTrackId);
|
||||
|
||||
final trackData = providerData['track'] as Map<String, dynamic>?;
|
||||
if (trackData != null) {
|
||||
final resolvedIsrc = normalizeOptionalString(
|
||||
trackData['isrc'] as String?,
|
||||
);
|
||||
|
||||
if (resolvedIsrc != null && _isValidISRC(resolvedIsrc)) {
|
||||
_log.d('Resolved ISRC from $provider: $resolvedIsrc');
|
||||
|
||||
final provReleaseDate = normalizeOptionalString(
|
||||
trackData['release_date'] as String?,
|
||||
);
|
||||
final provTrackNum = trackData['track_number'] as int?;
|
||||
final provDiscNum = trackData['disc_number'] as int?;
|
||||
|
||||
trackToDownload = Track(
|
||||
id: trackToDownload.id,
|
||||
name: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
artistId: trackToDownload.artistId,
|
||||
albumId: trackToDownload.albumId,
|
||||
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
|
||||
duration: trackToDownload.duration,
|
||||
isrc: resolvedIsrc,
|
||||
trackNumber:
|
||||
(trackToDownload.trackNumber != null &&
|
||||
trackToDownload.trackNumber! > 0)
|
||||
? trackToDownload.trackNumber
|
||||
: provTrackNum,
|
||||
discNumber:
|
||||
(trackToDownload.discNumber != null &&
|
||||
trackToDownload.discNumber! > 0)
|
||||
? trackToDownload.discNumber
|
||||
: provDiscNum,
|
||||
releaseDate: trackToDownload.releaseDate ?? provReleaseDate,
|
||||
deezerId: trackToDownload.deezerId,
|
||||
availability: trackToDownload.availability,
|
||||
albumType: trackToDownload.albumType,
|
||||
totalTracks: trackToDownload.totalTracks,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
|
||||
try {
|
||||
final deezerResult = await PlatformBridge.searchDeezerByISRC(
|
||||
resolvedIsrc,
|
||||
);
|
||||
if (deezerResult['success'] == true &&
|
||||
deezerResult['track_id'] != null) {
|
||||
deezerTrackId = deezerResult['track_id'].toString();
|
||||
_log.d(
|
||||
'Found Deezer track ID via $provider ISRC: $deezerTrackId',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to search Deezer by $provider ISRC: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to resolve ISRC from provider: $e');
|
||||
}
|
||||
|
||||
if (shouldAbortWork('during provider ISRC resolution')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedExtensionDownloadProvider &&
|
||||
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 +4002,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'track',
|
||||
spotifyId,
|
||||
);
|
||||
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
|
||||
final trackData = deezerData['track'];
|
||||
if (trackData is Map<String, dynamic>) {
|
||||
final rawId = trackData['spotify_id'] as String?;
|
||||
@@ -3839,14 +4137,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +4307,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +4319,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +4474,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
if (isM4aFile || shouldForceTidalSafM4aHandling) {
|
||||
// At this point filePath is guaranteed non-null by the checks above.
|
||||
final currentFilePath = filePath;
|
||||
|
||||
if (isContentUriPath && effectiveSafMode) {
|
||||
@@ -4521,11 +4812,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
} 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 +4848,39 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +4890,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
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 +4942,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
|
||||
if (metadataEmbeddingEnabled &&
|
||||
!wasExisting &&
|
||||
item.service == 'youtube' &&
|
||||
filePath != null) {
|
||||
final isOpusFile = filePath.endsWith('.opus');
|
||||
final isMp3File = filePath.endsWith('.mp3');
|
||||
|
||||
if (isOpusFile || isMp3File) {
|
||||
_log.i(
|
||||
'YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file',
|
||||
);
|
||||
updateItemStatus(
|
||||
item.id,
|
||||
DownloadStatus.downloading,
|
||||
progress: 0.95,
|
||||
);
|
||||
|
||||
final finalTrack = _buildTrackForMetadataEmbedding(
|
||||
trackToDownload,
|
||||
result,
|
||||
resolvedAlbumArtist,
|
||||
);
|
||||
final backendGenre = result['genre'] as String?;
|
||||
final backendLabel = result['label'] as String?;
|
||||
final backendCopyright = result['copyright'] as String?;
|
||||
|
||||
final isContentUriPath = isContentUri(filePath);
|
||||
if (isContentUriPath && effectiveSafMode) {
|
||||
final tempPath = await _copySafToTemp(filePath);
|
||||
if (tempPath != null) {
|
||||
try {
|
||||
if (isMp3File) {
|
||||
await _embedMetadataToMp3(
|
||||
tempPath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
tempPath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
final ext = isMp3File ? '.mp3' : '.opus';
|
||||
final newFileName = '${safBaseName ?? 'track'}$ext';
|
||||
final newUri = await _writeTempToSaf(
|
||||
treeUri: settings.downloadTreeUri,
|
||||
relativeDir: effectiveOutputDir,
|
||||
fileName: newFileName,
|
||||
mimeType: _mimeTypeForExt(ext),
|
||||
srcPath: tempPath,
|
||||
);
|
||||
if (newUri != null) {
|
||||
if (newUri != filePath) {
|
||||
await _deleteSafFile(filePath);
|
||||
}
|
||||
filePath = newUri;
|
||||
finalSafFileName = newFileName;
|
||||
_log.d('YouTube SAF metadata embedding completed');
|
||||
} else {
|
||||
_log.w('Failed to write metadata-updated file back to SAF');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('YouTube SAF metadata embedding failed: $e');
|
||||
} finally {
|
||||
try {
|
||||
await File(tempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (isMp3File) {
|
||||
await _embedMetadataToMp3(
|
||||
filePath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
} else {
|
||||
await _embedMetadataToOpus(
|
||||
filePath,
|
||||
finalTrack,
|
||||
genre: backendGenre ?? genre,
|
||||
label: backendLabel ?? label,
|
||||
copyright: backendCopyright,
|
||||
);
|
||||
}
|
||||
_log.d('YouTube metadata embedding completed');
|
||||
} catch (e) {
|
||||
_log.w('YouTube metadata embedding failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final itemAfterDownload = _findItemById(item.id);
|
||||
if (itemAfterDownload == null ||
|
||||
_isLocallyCancelled(item.id, item: itemAfterDownload)) {
|
||||
@@ -4746,9 +4966,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
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 +5280,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
_failedInSession++;
|
||||
|
||||
// Immediately cleanup connections after failure to prevent
|
||||
// poisoned connection pool from affecting subsequent downloads
|
||||
try {
|
||||
await PlatformBridge.cleanupConnections();
|
||||
} catch (e) {
|
||||
@@ -5117,7 +5332,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
);
|
||||
_failedInSession++;
|
||||
|
||||
// Immediately cleanup connections after exception
|
||||
try {
|
||||
await PlatformBridge.cleanupConnections();
|
||||
} catch (cleanupErr) {
|
||||
|
||||
@@ -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<ExploreState> {
|
||||
return const ExploreState();
|
||||
}
|
||||
|
||||
/// Restore cached home feed from SharedPreferences immediately on startup
|
||||
Future<void> _restoreFromCache() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -199,7 +198,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Save home feed to SharedPreferences for instant restore on next launch
|
||||
Future<void> _saveToCache(List<ExploreSection> sections) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -212,11 +210,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch home feed from spotify-web extension
|
||||
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
|
||||
_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<ExploreState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show loading spinner if we have no cached content to display
|
||||
final showLoading = !state.hasContent;
|
||||
state = state.copyWith(isLoading: showLoading, error: null);
|
||||
|
||||
@@ -247,14 +242,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
if (!extension.enabled || !extension.hasHomeFeed) {
|
||||
continue;
|
||||
}
|
||||
// If user has a preference, use that
|
||||
if (preferredId != null &&
|
||||
preferredId.isNotEmpty &&
|
||||
extension.id == preferredId) {
|
||||
targetExt = extension;
|
||||
break;
|
||||
}
|
||||
// Otherwise take the first available (fallback to spotify-web if found)
|
||||
if (targetExt == null || extension.id == 'spotify-web') {
|
||||
targetExt = extension;
|
||||
if (preferredId == null && extension.id == 'spotify-web') {
|
||||
@@ -317,7 +310,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
|
||||
lastFetched: DateTime.now(),
|
||||
);
|
||||
|
||||
// Save to disk cache for instant restore on next app launch
|
||||
_saveToCache(sections);
|
||||
} catch (e, stack) {
|
||||
_log.e('Error fetching home feed: $e', e, stack);
|
||||
|
||||
@@ -32,14 +32,12 @@ class Extension {
|
||||
final bool hasMetadataProvider;
|
||||
final bool hasDownloadProvider;
|
||||
final bool hasLyricsProvider;
|
||||
final bool
|
||||
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
|
||||
final bool skipMetadataEnrichment;
|
||||
final SearchBehavior? searchBehavior;
|
||||
final URLHandler? urlHandler;
|
||||
final TrackMatching? trackMatching;
|
||||
final PostProcessing? postProcessing;
|
||||
final Map<String, dynamic>
|
||||
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
|
||||
final Map<String, dynamic> capabilities;
|
||||
|
||||
const Extension({
|
||||
required this.id,
|
||||
@@ -198,12 +196,10 @@ class SearchBehavior {
|
||||
final String? placeholder;
|
||||
final bool primary;
|
||||
final String? icon;
|
||||
final String?
|
||||
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
|
||||
final String? thumbnailRatio;
|
||||
final int? thumbnailWidth;
|
||||
final int? thumbnailHeight;
|
||||
final List<SearchFilter>
|
||||
filters; // Available search filters (e.g., track, album, artist, playlist)
|
||||
final List<SearchFilter> filters;
|
||||
|
||||
const SearchBehavior({
|
||||
required this.enabled,
|
||||
@@ -239,11 +235,11 @@ class SearchBehavior {
|
||||
}
|
||||
|
||||
switch (thumbnailRatio) {
|
||||
case 'wide': // 16:9 - YouTube style
|
||||
case 'wide':
|
||||
return (defaultSize * 16 / 9, defaultSize);
|
||||
case 'portrait': // 2:3 - Poster style
|
||||
case 'portrait':
|
||||
return (defaultSize * 2 / 3, defaultSize);
|
||||
case 'square': // 1:1 - Album art style
|
||||
case 'square':
|
||||
default:
|
||||
return (defaultSize, defaultSize);
|
||||
}
|
||||
@@ -290,7 +286,6 @@ class PostProcessing {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL handler configuration for custom URL patterns
|
||||
class URLHandler {
|
||||
final bool enabled;
|
||||
final List<String> patterns;
|
||||
@@ -304,7 +299,6 @@ class URLHandler {
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if a URL matches any of the patterns
|
||||
bool matchesURL(String url) {
|
||||
if (!enabled || patterns.isEmpty) return false;
|
||||
final lowerUrl = url.toLowerCase();
|
||||
|
||||
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final destPath = p.join(coversDir.path, '$playlistId$ext');
|
||||
if (playlist.coverImagePath == destPath) return;
|
||||
|
||||
// Copy image to persistent location
|
||||
await File(sourceFilePath).copy(destPath);
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
|
||||
final playlist = state.playlistById(playlistId);
|
||||
if (playlist == null || playlist.coverImagePath == null) return;
|
||||
|
||||
// Delete the file if it exists
|
||||
final path = playlist.coverImagePath;
|
||||
if (path != null) {
|
||||
final file = File(path);
|
||||
|
||||
@@ -252,8 +252,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
_startProgressPolling();
|
||||
|
||||
// On iOS, start accessing the security-scoped bookmark so the Go backend
|
||||
// can read files outside the app sandbox.
|
||||
String? resolvedPath;
|
||||
bool didStartSecurityAccess = false;
|
||||
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
|
||||
@@ -275,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
try {
|
||||
final isSaf = effectiveFolderPath.startsWith('content://');
|
||||
|
||||
// Get all file paths from download history to exclude them.
|
||||
// Merge DB + in-memory state to avoid race when a fresh download has not
|
||||
// been flushed to SQLite yet.
|
||||
final downloadedPaths = await _historyDb.getAllFilePaths();
|
||||
final inMemoryHistoryPaths = ref
|
||||
.read(downloadHistoryProvider)
|
||||
@@ -298,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
|
||||
if (forceFullScan) {
|
||||
// Full scan path - ignores existing data
|
||||
final results = isSaf
|
||||
? await PlatformBridge.scanSafTree(effectiveFolderPath)
|
||||
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
|
||||
@@ -324,16 +318,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
// Full scan should replace library index entirely.
|
||||
await _db.clearAll();
|
||||
if (items.isNotEmpty) {
|
||||
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
|
||||
}
|
||||
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<LocalLibraryState> {
|
||||
errorCount: state.scanErrorCount,
|
||||
);
|
||||
} else {
|
||||
// Incremental scan path - only scans new/modified files
|
||||
final existingFiles = await _db.getFileModTimes();
|
||||
_log.i(
|
||||
'Incremental scan: ${existingFiles.length} existing files in database',
|
||||
@@ -423,7 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
|
||||
final scannedList =
|
||||
(result['files'] as List<dynamic>?) ??
|
||||
(result['scanned'] as List<dynamic>?) ??
|
||||
@@ -444,10 +428,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
|
||||
);
|
||||
|
||||
// Build the incremental merge base from SQLite, not the current
|
||||
// provider state. Startup auto-scan can fire before `state.items` has
|
||||
// finished loading, which would otherwise drop unchanged rows from the
|
||||
// in-memory library until a manual full rescan.
|
||||
final existingJson = await _db.getAll();
|
||||
final currentByPath = <String, LocalLibraryItem>{
|
||||
for (final item in existingJson.map(LocalLibraryItem.fromJson))
|
||||
@@ -468,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
}
|
||||
|
||||
// Upsert new/modified items (excluding downloaded files)
|
||||
final updatedItems = <LocalLibraryItem>[];
|
||||
int skippedDownloads = existingDownloadedPaths.length;
|
||||
if (scannedList.isNotEmpty) {
|
||||
@@ -502,11 +481,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_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 {
|
||||
|
||||
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
|
||||
const _maxRecentItems = 20;
|
||||
|
||||
/// Types of items that can be accessed
|
||||
enum RecentAccessType { artist, album, track, playlist }
|
||||
|
||||
/// Represents a recently accessed item
|
||||
class RecentAccessItem {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? subtitle; // Artist name for tracks/albums, null for artists
|
||||
final String? subtitle;
|
||||
final String? imageUrl;
|
||||
final RecentAccessType type;
|
||||
final DateTime accessedAt;
|
||||
final String? providerId; // Extension ID or 'deezer' for built-in
|
||||
final String? providerId;
|
||||
|
||||
const RecentAccessItem({
|
||||
required this.id,
|
||||
@@ -53,7 +51,6 @@ class RecentAccessItem {
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a unique key for deduplication
|
||||
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
||||
|
||||
@override
|
||||
@@ -67,7 +64,6 @@ class RecentAccessItem {
|
||||
int get hashCode => uniqueKey.hashCode;
|
||||
}
|
||||
|
||||
/// State for recent access history
|
||||
class RecentAccessState {
|
||||
final List<RecentAccessItem> items;
|
||||
final Set<String> hiddenDownloadIds;
|
||||
@@ -92,7 +88,6 @@ class RecentAccessState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing recent access history
|
||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
||||
|
||||
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record an access to an artist
|
||||
void recordArtistAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to an album
|
||||
void recordAlbumAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to a track
|
||||
void recordTrackAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Record an access to a playlist
|
||||
void recordPlaylistAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a specific item from history
|
||||
void removeItem(RecentAccessItem item) {
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
|
||||
}
|
||||
|
||||
/// Hide a download item from recents (without deleting the actual download)
|
||||
void hideDownloadFromRecents(String downloadId) {
|
||||
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
|
||||
}
|
||||
|
||||
/// Check if a download is hidden from recents
|
||||
bool isDownloadHidden(String downloadId) {
|
||||
return state.hiddenDownloadIds.contains(downloadId);
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
void clearHistory() {
|
||||
state = state.copyWith(items: []);
|
||||
unawaited(_appStateDb.clearRecentAccessRows());
|
||||
}
|
||||
|
||||
/// Clear hidden downloads (show all again)
|
||||
void clearHiddenDownloads() {
|
||||
state = state.copyWith(hiddenDownloadIds: {});
|
||||
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
|
||||
|
||||
@@ -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<AppSettings> {
|
||||
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
|
||||
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
|
||||
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
|
||||
|
||||
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
|
||||
@@ -40,7 +38,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
await _runMigrations(prefs);
|
||||
await _normalizeIosDownloadDirectoryIfNeeded();
|
||||
await _normalizeYouTubeBitratesIfNeeded();
|
||||
await _normalizeSongLinkRegionIfNeeded();
|
||||
}
|
||||
|
||||
@@ -122,6 +119,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
);
|
||||
}
|
||||
state = state.copyWith(lastSeenVersion: AppInfo.version);
|
||||
// Migration 7: YouTube is no longer a built-in service — reset to Tidal
|
||||
if (state.defaultService == 'youtube') {
|
||||
state = state.copyWith(defaultService: 'tidal');
|
||||
}
|
||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||
await _saveSettings();
|
||||
}
|
||||
@@ -153,49 +154,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
}
|
||||
|
||||
int _nearestSupportedBitrate(int value, List<int> supported) {
|
||||
var nearest = supported.first;
|
||||
var nearestDistance = (value - nearest).abs();
|
||||
|
||||
for (final option in supported.skip(1)) {
|
||||
final distance = (value - option).abs();
|
||||
// On tie, prefer higher quality bitrate.
|
||||
if (distance < nearestDistance ||
|
||||
(distance == nearestDistance && option > nearest)) {
|
||||
nearest = option;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
int _normalizeYouTubeOpusBitrate(int bitrate) {
|
||||
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
|
||||
}
|
||||
|
||||
int _normalizeYouTubeMp3Bitrate(int bitrate) {
|
||||
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
|
||||
}
|
||||
|
||||
Future<void> _normalizeYouTubeBitratesIfNeeded() async {
|
||||
final normalizedOpus = _normalizeYouTubeOpusBitrate(
|
||||
state.youtubeOpusBitrate,
|
||||
);
|
||||
final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate);
|
||||
|
||||
if (normalizedOpus == state.youtubeOpusBitrate &&
|
||||
normalizedMp3 == state.youtubeMp3Bitrate) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = state.copyWith(
|
||||
youtubeOpusBitrate: normalizedOpus,
|
||||
youtubeMp3Bitrate: normalizedMp3,
|
||||
);
|
||||
await _saveSettings();
|
||||
}
|
||||
|
||||
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
|
||||
if (!Platform.isIOS) return;
|
||||
|
||||
@@ -469,18 +427,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeOpusBitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
|
||||
state = state.copyWith(youtubeOpusBitrate: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setYoutubeMp3Bitrate(int bitrate) {
|
||||
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
|
||||
state = state.copyWith(youtubeMp3Bitrate: normalized);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAllFilesAccess(bool enabled) {
|
||||
state = state.copyWith(useAllFilesAccess: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url';
|
||||
int compareVersions(String v1, String v2) {
|
||||
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
|
||||
|
||||
|
||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||
|
||||
|
||||
for (var i = 0; i < maxLen; i++) {
|
||||
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
|
||||
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
|
||||
|
||||
|
||||
if (n1 < n2) return -1;
|
||||
if (n1 > n2) return 1;
|
||||
}
|
||||
@@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) {
|
||||
}
|
||||
|
||||
class StoreCategory {
|
||||
|
||||
static const String metadata = 'metadata';
|
||||
static const String download = 'download';
|
||||
static const String utility = 'utility';
|
||||
static const String lyrics = 'lyrics';
|
||||
static const String integration = 'integration';
|
||||
|
||||
static const List<String> all = [metadata, download, utility, lyrics, integration];
|
||||
static const List<String> all = [
|
||||
metadata,
|
||||
download,
|
||||
utility,
|
||||
lyrics,
|
||||
integration,
|
||||
];
|
||||
|
||||
static String getDisplayName(String category) {
|
||||
switch (category) {
|
||||
@@ -94,7 +99,8 @@ class StoreExtension {
|
||||
return StoreExtension(
|
||||
id: json['id'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
displayName:
|
||||
json['display_name'] as String? ?? json['name'] as String? ?? '',
|
||||
version: json['version'] as String? ?? '0.0.0',
|
||||
author: json['author'] as String? ?? 'Unknown',
|
||||
description: json['description'] as String? ?? '',
|
||||
@@ -117,7 +123,6 @@ class StoreExtension {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StoreState {
|
||||
final List<StoreExtension> extensions;
|
||||
final String? selectedCategory;
|
||||
@@ -160,11 +165,15 @@ class StoreState {
|
||||
}) {
|
||||
return StoreState(
|
||||
extensions: extensions ?? this.extensions,
|
||||
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
|
||||
selectedCategory: clearCategory
|
||||
? null
|
||||
: (selectedCategory ?? this.selectedCategory),
|
||||
searchQuery: searchQuery ?? this.searchQuery,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isDownloading: isDownloading ?? this.isDownloading,
|
||||
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
|
||||
downloadingId: clearDownloadingId
|
||||
? null
|
||||
: (downloadingId ?? this.downloadingId),
|
||||
error: clearError ? null : (error ?? this.error),
|
||||
isInitialized: isInitialized ?? this.isInitialized,
|
||||
registryUrl: registryUrl ?? this.registryUrl,
|
||||
@@ -180,13 +189,16 @@ class StoreState {
|
||||
|
||||
if (searchQuery.isNotEmpty) {
|
||||
final query = searchQuery.toLowerCase();
|
||||
result = result.where((e) =>
|
||||
e.name.toLowerCase().contains(query) ||
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.description.toLowerCase().contains(query) ||
|
||||
e.author.toLowerCase().contains(query) ||
|
||||
e.tags.any((t) => t.toLowerCase().contains(query))
|
||||
).toList();
|
||||
result = result
|
||||
.where(
|
||||
(e) =>
|
||||
e.name.toLowerCase().contains(query) ||
|
||||
e.displayName.toLowerCase().contains(query) ||
|
||||
e.description.toLowerCase().contains(query) ||
|
||||
e.author.toLowerCase().contains(query) ||
|
||||
e.tags.any((t) => t.toLowerCase().contains(query)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -206,23 +218,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
Future<void> initialize(String cacheDir) async {
|
||||
if (state.isInitialized) return;
|
||||
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
// Load saved registry URL early to avoid UI flash (empty → setup screen)
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||
|
||||
state = state.copyWith(
|
||||
isLoading: true,
|
||||
clearError: true,
|
||||
registryUrl: savedUrl,
|
||||
);
|
||||
|
||||
try {
|
||||
await PlatformBridge.initExtensionStore(cacheDir);
|
||||
|
||||
// Load saved registry URL from SharedPreferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
|
||||
|
||||
if (savedUrl.isNotEmpty) {
|
||||
await PlatformBridge.setStoreRegistryUrl(savedUrl);
|
||||
state = state.copyWith(registryUrl: savedUrl);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
state = state.copyWith(isInitialized: true, isLoading: false);
|
||||
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})');
|
||||
_log.i(
|
||||
'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})',
|
||||
);
|
||||
} catch (e) {
|
||||
_log.e('Failed to initialize store: $e');
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
@@ -247,13 +264,12 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
// Read back the resolved URL (may differ from input after normalisation).
|
||||
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
|
||||
|
||||
// Persist to SharedPreferences
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
|
||||
|
||||
state = state.copyWith(
|
||||
registryUrl: resolvedUrl,
|
||||
extensions: const [], // Clear old extensions
|
||||
extensions: const [],
|
||||
);
|
||||
|
||||
_log.i('Registry URL set to: $resolvedUrl');
|
||||
@@ -292,7 +308,9 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(isLoading: true, clearError: true);
|
||||
|
||||
try {
|
||||
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
|
||||
final extensions = await PlatformBridge.getStoreExtensions(
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
state = state.copyWith(
|
||||
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
|
||||
isLoading: false,
|
||||
@@ -320,12 +338,23 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
state = state.copyWith(searchQuery: '', clearCategory: true);
|
||||
}
|
||||
|
||||
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
Future<bool> installExtension(
|
||||
String extensionId,
|
||||
String tempDir,
|
||||
String extensionsDir,
|
||||
) async {
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
downloadingId: extensionId,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
_log.i('Downloading extension: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||
extensionId,
|
||||
tempDir,
|
||||
);
|
||||
|
||||
_log.i('Installing extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
@@ -340,18 +369,28 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to install extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
state = state.copyWith(
|
||||
isDownloading: false,
|
||||
clearDownloadingId: true,
|
||||
error: e.toString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<bool> updateExtension(String extensionId, String tempDir) async {
|
||||
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
|
||||
state = state.copyWith(
|
||||
isDownloading: true,
|
||||
downloadingId: extensionId,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
try {
|
||||
_log.i('Downloading update for: $extensionId');
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir);
|
||||
final downloadPath = await PlatformBridge.downloadStoreExtension(
|
||||
extensionId,
|
||||
tempDir,
|
||||
);
|
||||
|
||||
_log.i('Upgrading extension from: $downloadPath');
|
||||
final extNotifier = ref.read(extensionProvider.notifier);
|
||||
@@ -366,7 +405,11 @@ class StoreNotifier extends Notifier<StoreState> {
|
||||
return success;
|
||||
} catch (e) {
|
||||
_log.e('Failed to update extension: $e');
|
||||
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString());
|
||||
state = state.copyWith(
|
||||
isDownloading: false,
|
||||
clearDownloadingId: true,
|
||||
error: e.toString(),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
await _saveToStorage();
|
||||
}
|
||||
|
||||
/// Set custom seed color (used when dynamic color is disabled)
|
||||
Future<void> setSeedColor(Color color) async {
|
||||
state = state.copyWith(seedColorValue: color.toARGB32());
|
||||
await _saveToStorage();
|
||||
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final List<SearchAlbum>? searchAlbums; // For search results (albums)
|
||||
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String?
|
||||
searchExtensionId; // Extension ID used for current search results
|
||||
final String?
|
||||
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
|
||||
final String?
|
||||
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
|
||||
final List<ArtistAlbum>? artistAlbums;
|
||||
final List<Track>? artistTopTracks;
|
||||
final List<SearchArtist>? searchArtists;
|
||||
final List<SearchAlbum>? searchAlbums;
|
||||
final List<SearchPlaylist>? searchPlaylists;
|
||||
final bool hasSearchText;
|
||||
final bool isShowingRecentAccess;
|
||||
final String? searchExtensionId;
|
||||
final String? selectedSearchFilter;
|
||||
final String? searchSource;
|
||||
|
||||
const TrackState({
|
||||
this.tracks = const [],
|
||||
@@ -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<TrackState> {
|
||||
return const TrackState();
|
||||
}
|
||||
|
||||
/// Check if request is still valid (not cancelled by newer request)
|
||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||
|
||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||
@@ -217,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
if (extensionHandler != null) {
|
||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||
|
||||
// Retry logic for extension URL handlers (up to 3 attempts)
|
||||
Map<String, dynamic>? result;
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
result = await PlatformBridge.handleURLWithExtension(url);
|
||||
@@ -541,7 +536,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
return;
|
||||
}
|
||||
|
||||
// If URL doesn't match any known service, it's unrecognized
|
||||
final isSpotifyUrl =
|
||||
url.contains('open.spotify.com') ||
|
||||
url.contains('spotify.link') ||
|
||||
@@ -643,7 +637,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}) async {
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
// Preserve selected filter during loading
|
||||
final currentFilter = filterOverride ?? state.selectedSearchFilter;
|
||||
|
||||
state = TrackState(
|
||||
@@ -662,7 +655,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final includeExtensions =
|
||||
settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
|
||||
// Determine the effective search provider
|
||||
final effectiveProvider = builtInSearchProvider ?? 'deezer';
|
||||
|
||||
_log.i(
|
||||
@@ -672,7 +664,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
Map<String, dynamic> results;
|
||||
List<Map<String, dynamic>> metadataTrackResults = [];
|
||||
|
||||
// Only use metadata providers for Deezer search (default behavior)
|
||||
if (effectiveProvider == 'deezer') {
|
||||
try {
|
||||
_log.d('Calling metadata provider search API...');
|
||||
@@ -692,7 +683,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Call the appropriate search API
|
||||
switch (effectiveProvider) {
|
||||
case 'tidal':
|
||||
_log.d('Calling Tidal search API...');
|
||||
@@ -808,9 +798,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
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;
|
||||
@@ -837,7 +826,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve filter during loading
|
||||
state.selectedSearchFilter,
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -876,9 +865,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
isLoading: false,
|
||||
hasSearchText: state.hasSearchText,
|
||||
isShowingRecentAccess: state.isShowingRecentAccess,
|
||||
searchExtensionId: extensionId, // Store which extension was used
|
||||
selectedSearchFilter:
|
||||
state.selectedSearchFilter, // Preserve selected filter
|
||||
searchExtensionId: extensionId,
|
||||
selectedSearchFilter: state.selectedSearchFilter,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
if (!_isRequestValid(requestId)) return;
|
||||
@@ -934,7 +922,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
tracks[index] = updatedTrack;
|
||||
state = state.copyWith(tracks: tracks);
|
||||
} catch (_) {
|
||||
// Silently ignore update failures - track may have been removed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -942,7 +929,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = const TrackState();
|
||||
}
|
||||
|
||||
/// Set selected search filter for extension search
|
||||
void setSearchFilter(String? filter) {
|
||||
if (state.selectedSearchFilter == filter) return;
|
||||
state = state.copyWith(
|
||||
@@ -951,7 +937,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Set search text state for back button handling
|
||||
void setSearchText(bool hasText) {
|
||||
if (state.hasSearchText == hasText) {
|
||||
return;
|
||||
@@ -966,7 +951,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
|
||||
/// Set tracks from a collection (album/playlist) opened from search results
|
||||
void setTracksFromCollection({
|
||||
required List<Track> tracks,
|
||||
String? albumName,
|
||||
@@ -1127,7 +1111,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
'isrc': isrc,
|
||||
'track_name': track.name,
|
||||
'artist_name': track.artistName,
|
||||
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
|
||||
'spotify_id': track.id,
|
||||
'service': 'tidal',
|
||||
});
|
||||
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
|
||||
|
||||
@@ -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<AlbumScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId;
|
||||
}
|
||||
if (widget.albumId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.albumId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -257,8 +268,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
if (_isLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: AlbumTrackListSkeleton(itemCount: 10),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
@@ -534,9 +545,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _AlbumTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _AlbumTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
@@ -551,6 +565,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
@@ -576,7 +591,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
final tracks = _tracks;
|
||||
if (tracks == null || tracks.isEmpty) return;
|
||||
|
||||
// Skip already-downloaded tracks
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
@@ -623,6 +637,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
context,
|
||||
trackName: '${tracksToQueue.length} tracks',
|
||||
artistName: widget.albumName,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
|
||||
@@ -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<ArtistScreen> {
|
||||
return tileSize + 64 + ((textScale - 1) * 14);
|
||||
}
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
return widget.extensionId;
|
||||
}
|
||||
if (widget.artistId.startsWith('tidal:')) return 'tidal';
|
||||
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (widget.artistId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -481,12 +492,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
hasDiscography: hasDiscography,
|
||||
),
|
||||
if (_isLoadingDiscography)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: ArtistScreenSkeleton()),
|
||||
if (_error != null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -889,6 +895,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
_fetchAndQueueAlbums(albums, service, quality);
|
||||
},
|
||||
@@ -948,7 +955,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
|
||||
fetchedCount++;
|
||||
|
||||
// Update progress dialog
|
||||
if (mounted) {
|
||||
_FetchingProgressDialog.updateProgress(
|
||||
context,
|
||||
@@ -979,7 +985,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check which tracks are already downloaded
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final tracksToQueue = <Track>[];
|
||||
int skippedCount = 0;
|
||||
@@ -1030,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
content: Text(message),
|
||||
action: SnackBarAction(
|
||||
label: context.l10n.snackbarViewQueue,
|
||||
onPressed: () {
|
||||
// Navigate to queue tab (index 1)
|
||||
// This will be handled by the navigation system
|
||||
},
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1154,6 +1156,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
imageUrl.isNotEmpty &&
|
||||
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
||||
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
String? listenersText;
|
||||
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
||||
if (listeners != null && listeners > 0) {
|
||||
@@ -1224,7 +1228,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.3),
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
colorScheme.surface,
|
||||
isDark
|
||||
? colorScheme.surface
|
||||
: Colors.black.withValues(alpha: 0.85),
|
||||
],
|
||||
stops: const [0.0, 0.5, 0.75, 1.0],
|
||||
),
|
||||
@@ -1265,7 +1271,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
listenersText,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
@@ -1689,6 +1695,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
if (settings.askQualityBeforeDownload) {
|
||||
DownloadServicePicker.show(
|
||||
context,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
if (!mounted) return;
|
||||
enqueue(service, quality: quality);
|
||||
@@ -1839,29 +1846,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.surface.withValues(alpha: 0.9),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
child: AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 28,
|
||||
unselectedColor: colorScheme.surface.withValues(
|
||||
alpha: 0.9,
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 18,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (showTypeBadge)
|
||||
@@ -2070,7 +2062,6 @@ class _FetchingProgressDialog extends StatefulWidget {
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
// Static method to update progress from outside
|
||||
static void updateProgress(BuildContext context, int current, int total) {
|
||||
final state = context
|
||||
.findAncestorStateOfType<_FetchingProgressDialogState>();
|
||||
@@ -2143,7 +2134,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Progress bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
||||
final String albumName;
|
||||
@@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
final tracks =
|
||||
allItems.where((item) {
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist =
|
||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
@@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
return itemKey == _albumLookupKey;
|
||||
}).toList()..sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
@@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
if (!mounted) return;
|
||||
|
||||
final result = await navigator.push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
slidePageRoute(page: TrackMetadataScreen(item: item)),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
@@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
);
|
||||
@@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
final discNumbers = _getSortedDiscNumbers(tracks);
|
||||
final List<Widget> children = [];
|
||||
var revealIndex = 0;
|
||||
|
||||
for (final discNumber in discNumbers) {
|
||||
final discTracks = discMap[discNumber];
|
||||
@@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
children.add(
|
||||
KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
child: StaggeredListItem(
|
||||
index: revealIndex++,
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -796,28 +795,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
@@ -1123,7 +1105,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
? 'Opus'
|
||||
: null;
|
||||
if (ext == null || ext == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
|
||||
if (isLosslessTarget && !isLosslessSource) continue;
|
||||
|
||||
+375
-106
@@ -28,6 +28,7 @@ import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/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<HomeTab>
|
||||
Map<String, (double, double)>? _thumbnailSizesCache;
|
||||
List<Track>? _searchBucketsSourceTracks;
|
||||
_SearchResultBuckets? _searchBucketsCache;
|
||||
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
|
||||
|
||||
double _responsiveScale({
|
||||
required BuildContext context,
|
||||
@@ -280,13 +294,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
double _exploreCardSize(BuildContext context) {
|
||||
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return 120 * scale * (1 + (textScale - 1) * 0.12);
|
||||
return 145 * scale * (1 + (textScale - 1) * 0.12);
|
||||
}
|
||||
|
||||
double _exploreSectionHeight(BuildContext context) {
|
||||
final cardSize = _exploreCardSize(context);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return cardSize + 55 + ((textScale - 1) * 12);
|
||||
return cardSize + 58 + ((textScale - 1) * 12);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -564,6 +578,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
|
||||
if (_lastSearchQuery == searchKey) return;
|
||||
_lastSearchQuery = searchKey;
|
||||
_searchSortOption = _SearchSortOption.defaultOrder;
|
||||
|
||||
final isBuiltInProvider =
|
||||
searchProvider != null &&
|
||||
@@ -698,6 +713,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
playlistName: trackState.playlistName!,
|
||||
coverUrl: trackState.coverUrl,
|
||||
tracks: trackState.tracks,
|
||||
recommendedService:
|
||||
trackState.searchExtensionId ?? trackState.searchSource,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1281,8 +1298,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
exploreLoading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: TrackListSkeleton(itemCount: 5),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1485,7 +1502,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
if (hasGreeting && index == 0) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
greeting,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
@@ -1500,7 +1517,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return _buildExploreSection(sections[sectionIndex], colorScheme);
|
||||
}
|
||||
|
||||
return const SizedBox(height: 16);
|
||||
return const SizedBox(height: 24);
|
||||
}, childCount: totalCount),
|
||||
),
|
||||
];
|
||||
@@ -1516,7 +1533,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
|
||||
child: Text(
|
||||
section.title,
|
||||
style: Theme.of(
|
||||
@@ -1532,7 +1549,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
itemCount: section.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = section.items[index];
|
||||
return _buildExploreItem(item, colorScheme);
|
||||
return StaggeredListItem(
|
||||
index: index,
|
||||
staggerDelay: const Duration(milliseconds: 50),
|
||||
child: _buildExploreItem(item, colorScheme),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1579,7 +1600,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
isArtist ? cardSize / 2 : 8,
|
||||
isArtist ? cardSize / 2 : 10,
|
||||
),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
@@ -1618,8 +1639,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isArtist ? TextAlign.center : TextAlign.start,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
@@ -1632,7 +1653,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -2122,7 +2143,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify') {
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -2162,7 +2185,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
} else if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify') {
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -2210,7 +2235,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (item.providerId != null &&
|
||||
item.providerId!.isNotEmpty &&
|
||||
item.providerId != 'deezer' &&
|
||||
item.providerId != 'spotify') {
|
||||
item.providerId != 'spotify' &&
|
||||
item.providerId != 'tidal' &&
|
||||
item.providerId != 'qobuz') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
@@ -2248,14 +2275,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
if (!mounted) return;
|
||||
final result = await navigator.push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
slidePageRoute(page: TrackMetadataScreen(item: item)),
|
||||
);
|
||||
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
|
||||
item.filePath,
|
||||
@@ -2393,6 +2413,168 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Search result sorting ──────────────────────────────────────────────
|
||||
|
||||
String _sortOptionLabel(_SearchSortOption option) {
|
||||
switch (option) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
return context.l10n.searchSortDefault;
|
||||
case _SearchSortOption.titleAsc:
|
||||
return context.l10n.searchSortTitleAZ;
|
||||
case _SearchSortOption.titleDesc:
|
||||
return context.l10n.searchSortTitleZA;
|
||||
case _SearchSortOption.artistAsc:
|
||||
return context.l10n.searchSortArtistAZ;
|
||||
case _SearchSortOption.artistDesc:
|
||||
return context.l10n.searchSortArtistZA;
|
||||
case _SearchSortOption.durationAsc:
|
||||
return context.l10n.searchSortDurationShort;
|
||||
case _SearchSortOption.durationDesc:
|
||||
return context.l10n.searchSortDurationLong;
|
||||
case _SearchSortOption.dateAsc:
|
||||
return context.l10n.searchSortDateOldest;
|
||||
case _SearchSortOption.dateDesc:
|
||||
return context.l10n.searchSortDateNewest;
|
||||
}
|
||||
}
|
||||
|
||||
void _showSortOptions(ColorScheme colorScheme) {
|
||||
var tempSort = _searchSortOption;
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: colorScheme.surfaceContainerLow,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (ctx, setSheetState) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.searchSortTitle,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () => setSheetState(
|
||||
() => tempSort = _SearchSortOption.defaultOrder,
|
||||
),
|
||||
child: Text(context.l10n.libraryFilterReset),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _SearchSortOption.values.map((option) {
|
||||
return FilterChip(
|
||||
label: Text(_sortOptionLabel(option)),
|
||||
selected: tempSort == option,
|
||||
showCheckmark: false,
|
||||
onSelected: (_) =>
|
||||
setSheetState(() => tempSort = option),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
if (_searchSortOption != tempSort) {
|
||||
setState(() {
|
||||
_searchSortOption = tempSort;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(context.l10n.libraryFilterApply),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<T> _applySortToList<T>(
|
||||
List<T> items,
|
||||
String Function(T) getName,
|
||||
String Function(T) getArtist,
|
||||
int Function(T) getDuration,
|
||||
String? Function(T) getDate,
|
||||
) {
|
||||
if (_searchSortOption == _SearchSortOption.defaultOrder) return items;
|
||||
final sorted = List<T>.of(items);
|
||||
switch (_searchSortOption) {
|
||||
case _SearchSortOption.defaultOrder:
|
||||
break;
|
||||
case _SearchSortOption.titleAsc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getName(a).toLowerCase().compareTo(getName(b).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.titleDesc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getName(b).toLowerCase().compareTo(getName(a).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.artistAsc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.artistDesc:
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()),
|
||||
);
|
||||
case _SearchSortOption.durationAsc:
|
||||
sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b)));
|
||||
case _SearchSortOption.durationDesc:
|
||||
sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a)));
|
||||
case _SearchSortOption.dateAsc:
|
||||
sorted.sort((a, b) {
|
||||
final da = getDate(a) ?? '';
|
||||
final db = getDate(b) ?? '';
|
||||
return da.compareTo(db);
|
||||
});
|
||||
case _SearchSortOption.dateDesc:
|
||||
sorted.sort((a, b) {
|
||||
final da = getDate(a) ?? '';
|
||||
final db = getDate(b) ?? '';
|
||||
return db.compareTo(da);
|
||||
});
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
List<Widget> _buildSearchResults({
|
||||
required List<Track> tracks,
|
||||
required List<SearchArtist>? searchArtists,
|
||||
@@ -2406,6 +2588,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
required bool showLocalLibraryIndicator,
|
||||
required Map<String, (double, double)> thumbnailSizesByExtensionId,
|
||||
}) {
|
||||
final hasActualData =
|
||||
tracks.isNotEmpty ||
|
||||
(searchArtists != null && searchArtists.isNotEmpty) ||
|
||||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
|
||||
(searchPlaylists != null && searchPlaylists.isNotEmpty);
|
||||
|
||||
if (!hasActualData && isLoading) {
|
||||
return [const SliverToBoxAdapter(child: HomeSearchSkeleton())];
|
||||
}
|
||||
if (!hasResults) {
|
||||
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
|
||||
}
|
||||
@@ -2417,6 +2608,59 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
final playlistItems = buckets.playlistItems;
|
||||
final artistItems = buckets.artistItems;
|
||||
|
||||
final sortedArtists = searchArtists != null && searchArtists.isNotEmpty
|
||||
? _applySortToList<SearchArtist>(
|
||||
searchArtists,
|
||||
(a) => a.name,
|
||||
(a) => a.name,
|
||||
(a) => 0,
|
||||
(a) => null,
|
||||
)
|
||||
: searchArtists;
|
||||
|
||||
final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty
|
||||
? _applySortToList<SearchAlbum>(
|
||||
searchAlbums,
|
||||
(a) => a.name,
|
||||
(a) => a.artists,
|
||||
(a) => 0,
|
||||
(a) => a.releaseDate,
|
||||
)
|
||||
: searchAlbums;
|
||||
|
||||
final sortedPlaylists =
|
||||
searchPlaylists != null && searchPlaylists.isNotEmpty
|
||||
? _applySortToList<SearchPlaylist>(
|
||||
searchPlaylists,
|
||||
(p) => p.name,
|
||||
(p) => p.owner,
|
||||
(p) => 0,
|
||||
(p) => null,
|
||||
)
|
||||
: searchPlaylists;
|
||||
|
||||
List<Track> sortedTracks;
|
||||
List<int> sortedTrackIndexes;
|
||||
if (realTracks.isNotEmpty &&
|
||||
_searchSortOption != _SearchSortOption.defaultOrder) {
|
||||
final paired = List.generate(
|
||||
realTracks.length,
|
||||
(i) => (realTracks[i], realTrackIndexes[i]),
|
||||
);
|
||||
final sortedPairs = _applySortToList<(Track, int)>(
|
||||
paired,
|
||||
(p) => p.$1.name,
|
||||
(p) => p.$1.artistName,
|
||||
(p) => p.$1.duration,
|
||||
(p) => p.$1.releaseDate,
|
||||
);
|
||||
sortedTracks = sortedPairs.map((p) => p.$1).toList();
|
||||
sortedTrackIndexes = sortedPairs.map((p) => p.$2).toList();
|
||||
} else {
|
||||
sortedTracks = realTracks;
|
||||
sortedTrackIndexes = realTrackIndexes;
|
||||
}
|
||||
|
||||
final slivers = <Widget>[
|
||||
if (error != null)
|
||||
SliverToBoxAdapter(
|
||||
@@ -2434,24 +2678,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
];
|
||||
|
||||
if (searchArtists != null && searchArtists.isNotEmpty) {
|
||||
bool sortButtonShown = false;
|
||||
|
||||
if (sortedArtists != null && sortedArtists.isNotEmpty) {
|
||||
slivers.addAll(
|
||||
_buildVirtualizedResultSection(
|
||||
title: context.l10n.searchArtists,
|
||||
itemCount: searchArtists.length,
|
||||
itemCount: sortedArtists.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _SearchArtistItemWidget(
|
||||
key: ValueKey('search-artist-${searchArtists[index].id}'),
|
||||
artist: searchArtists[index],
|
||||
key: ValueKey('search-artist-${sortedArtists[index].id}'),
|
||||
artist: sortedArtists[index],
|
||||
showDivider: showDivider,
|
||||
onTap: () => _navigateToArtist(
|
||||
searchArtists[index].id,
|
||||
searchArtists[index].name,
|
||||
searchArtists[index].imageUrl,
|
||||
sortedArtists[index].id,
|
||||
sortedArtists[index].name,
|
||||
sortedArtists[index].imageUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (artistItems.isNotEmpty) {
|
||||
@@ -2460,6 +2708,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
title: context.l10n.searchArtists,
|
||||
itemCount: artistItems.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||
key: ValueKey('artist-${artistItems[index].id}'),
|
||||
item: artistItems[index],
|
||||
@@ -2468,22 +2717,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (searchAlbums != null && searchAlbums.isNotEmpty) {
|
||||
if (sortedAlbums != null && sortedAlbums.isNotEmpty) {
|
||||
slivers.addAll(
|
||||
_buildVirtualizedResultSection(
|
||||
title: context.l10n.searchAlbums,
|
||||
itemCount: searchAlbums.length,
|
||||
itemCount: sortedAlbums.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _SearchAlbumItemWidget(
|
||||
key: ValueKey('search-album-${searchAlbums[index].id}'),
|
||||
album: searchAlbums[index],
|
||||
key: ValueKey('search-album-${sortedAlbums[index].id}'),
|
||||
album: sortedAlbums[index],
|
||||
showDivider: showDivider,
|
||||
onTap: () => _navigateToSearchAlbum(searchAlbums[index]),
|
||||
onTap: () => _navigateToSearchAlbum(sortedAlbums[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (albumItems.isNotEmpty) {
|
||||
@@ -2492,6 +2744,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
title: context.l10n.searchAlbums,
|
||||
itemCount: albumItems.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||
key: ValueKey('album-${albumItems[index].id}'),
|
||||
item: albumItems[index],
|
||||
@@ -2500,22 +2753,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (searchPlaylists != null && searchPlaylists.isNotEmpty) {
|
||||
if (sortedPlaylists != null && sortedPlaylists.isNotEmpty) {
|
||||
slivers.addAll(
|
||||
_buildVirtualizedResultSection(
|
||||
title: context.l10n.searchPlaylists,
|
||||
itemCount: searchPlaylists.length,
|
||||
itemCount: sortedPlaylists.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget(
|
||||
key: ValueKey('search-playlist-${searchPlaylists[index].id}'),
|
||||
playlist: searchPlaylists[index],
|
||||
key: ValueKey('search-playlist-${sortedPlaylists[index].id}'),
|
||||
playlist: sortedPlaylists[index],
|
||||
showDivider: showDivider,
|
||||
onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]),
|
||||
onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (playlistItems.isNotEmpty) {
|
||||
@@ -2524,6 +2780,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
title: context.l10n.searchPlaylists,
|
||||
itemCount: playlistItems.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _CollectionItemWidget(
|
||||
key: ValueKey('playlist-${playlistItems[index].id}'),
|
||||
item: playlistItems[index],
|
||||
@@ -2532,20 +2789,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
);
|
||||
sortButtonShown = true;
|
||||
}
|
||||
|
||||
if (realTracks.isNotEmpty) {
|
||||
if (sortedTracks.isNotEmpty) {
|
||||
slivers.addAll(
|
||||
_buildVirtualizedResultSection(
|
||||
title: context.l10n.searchSongs,
|
||||
itemCount: realTracks.length,
|
||||
itemCount: sortedTracks.length,
|
||||
colorScheme: colorScheme,
|
||||
showSortButton: !sortButtonShown,
|
||||
itemBuilder: (index, showDivider) => _TrackItemWithStatus(
|
||||
key: ValueKey(realTracks[index].id),
|
||||
track: realTracks[index],
|
||||
index: realTrackIndexes[index],
|
||||
key: ValueKey(sortedTracks[index].id),
|
||||
track: sortedTracks[index],
|
||||
index: sortedTrackIndexes[index],
|
||||
showDivider: showDivider,
|
||||
onDownload: () => _downloadTrack(realTrackIndexes[index]),
|
||||
onDownload: () => _downloadTrack(sortedTrackIndexes[index]),
|
||||
searchExtensionId: searchExtensionId,
|
||||
showLocalLibraryIndicator: showLocalLibraryIndicator,
|
||||
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
|
||||
@@ -2563,6 +2822,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
required int itemCount,
|
||||
required ColorScheme colorScheme,
|
||||
required Widget Function(int index, bool showDivider) itemBuilder,
|
||||
bool showSortButton = false,
|
||||
}) {
|
||||
final sectionColor = Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(
|
||||
@@ -2574,12 +2834,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
return [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showSortButton)
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _showSortOptions(colorScheme),
|
||||
icon: Icon(
|
||||
Icons.swap_vert,
|
||||
size: 18,
|
||||
color: _searchSortOption != _SearchSortOption.defaultOrder
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
label: Text(
|
||||
_searchSortOption != _SearchSortOption.defaultOrder
|
||||
? _sortOptionLabel(_searchSortOption)
|
||||
: context.l10n.libraryFilterSort,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color:
|
||||
_searchSortOption != _SearchSortOption.defaultOrder
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -2587,19 +2882,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final isFirst = index == 0;
|
||||
final isLast = index == itemCount - 1;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: sectionColor,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isFirst ? const Radius.circular(20) : Radius.zero,
|
||||
bottom: isLast ? const Radius.circular(20) : Radius.zero,
|
||||
return StaggeredListItem(
|
||||
index: index,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: sectionColor,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: isFirst ? const Radius.circular(20) : Radius.zero,
|
||||
bottom: isLast ? const Radius.circular(20) : Radius.zero,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: itemBuilder(index, !isLast),
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: itemBuilder(index, !isLast),
|
||||
),
|
||||
);
|
||||
}, childCount: itemCount),
|
||||
@@ -2793,7 +3091,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
}
|
||||
|
||||
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<HomeTab>
|
||||
_triggerSearchWithFilter(null);
|
||||
},
|
||||
showCheckmark: false,
|
||||
selectedColor: colorScheme.primaryContainer,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(
|
||||
color: selectedFilter == null
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: selectedFilter == null
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
...filters.map((filter) {
|
||||
@@ -2859,24 +3146,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
_triggerSearchWithFilter(filter.id);
|
||||
},
|
||||
showCheckmark: false,
|
||||
selectedColor: colorScheme.primaryContainer,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
labelStyle: TextStyle(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
avatar: filter.icon != null
|
||||
? Icon(
|
||||
_getFilterIcon(filter.icon!),
|
||||
size: 18,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
)
|
||||
? Icon(_getFilterIcon(filter.icon!), size: 18)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
@@ -2913,7 +3184,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
if (text.isEmpty || text.length < _minLiveSearchChars) return;
|
||||
if (text.startsWith('http') || text.startsWith('spotify:')) return;
|
||||
|
||||
// Reset last search query to force new search
|
||||
_lastSearchQuery = null;
|
||||
_performSearch(text, filterOverride: filter);
|
||||
}
|
||||
@@ -2931,15 +3201,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.outline.withValues(alpha: 0.5),
|
||||
),
|
||||
borderSide: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
@@ -2987,6 +3253,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
||||
),
|
||||
),
|
||||
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<String>(
|
||||
value: 'tidal',
|
||||
child: Row(
|
||||
@@ -3143,7 +3410,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// Built-in Qobuz search option
|
||||
PopupMenuItem<String>(
|
||||
value: 'qobuz',
|
||||
child: Row(
|
||||
@@ -3966,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Extract artist info from album response
|
||||
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
|
||||
final artistName = result['artists'] as String?;
|
||||
|
||||
@@ -4024,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
body: const AlbumTrackListSkeleton(
|
||||
itemCount: 10,
|
||||
showCoverHeader: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ExtensionArtistScreen> {
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(widget.artistName)),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
body: const ArtistScreenSkeleton(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
@@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.edit_outlined,
|
||||
title: context.l10n.collectionRenamePlaylist,
|
||||
onTap: () {
|
||||
@@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.image_outlined,
|
||||
title: context.l10n.collectionPlaylistChangeCover,
|
||||
onTap: () {
|
||||
@@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
|
||||
_PlaylistOptionTile(
|
||||
BottomSheetOptionTile(
|
||||
icon: Icons.delete_outline,
|
||||
iconColor: colorScheme.error,
|
||||
title: context.l10n.collectionDeletePlaylist,
|
||||
@@ -543,40 +544,3 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Styled like _OptionTile in track_collection_quick_actions.dart
|
||||
class _PlaylistOptionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _PlaylistOptionTile({
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: iconColor ?? colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
|
||||
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
|
||||
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
|
||||
final LibraryTracksFolderMode mode;
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -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
|
||||
@@ -1500,16 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
);
|
||||
|
||||
if (historyItem != null) {
|
||||
await Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(item: historyItem),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1524,16 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget {
|
||||
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
|
||||
|
||||
if (localItem != null) {
|
||||
await Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(localItem: localItem),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<LocalAlbumScreen> {
|
||||
if (tracks.isEmpty) return null;
|
||||
final first = tracks.first;
|
||||
|
||||
// For lossy formats, use bitrate
|
||||
if (first.bitrate != null && first.bitrate! > 0) {
|
||||
final fmt = first.format?.toUpperCase() ?? '';
|
||||
final firstBitrate = first.bitrate;
|
||||
@@ -543,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
return '$fmt ${firstBitrate}kbps'.trim();
|
||||
}
|
||||
|
||||
// For lossless formats, use bit depth / sample rate
|
||||
if (first.bitDepth == null ||
|
||||
first.bitDepth == 0 ||
|
||||
first.sampleRate == null) {
|
||||
@@ -630,7 +629,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
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<LocalAlbumScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSelectionMode) ...[
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
AnimatedSelectionCheckbox(
|
||||
visible: true,
|
||||
selected: isSelected,
|
||||
colorScheme: colorScheme,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
@@ -1382,7 +1367,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
}
|
||||
}
|
||||
if (currentFormat == null || currentFormat == targetFormat) continue;
|
||||
// Skip lossy sources when target is lossless (pointless re-encoding)
|
||||
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
|
||||
final isLosslessSource =
|
||||
currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
@@ -1503,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
bitrate: bitrate,
|
||||
metadata: metadata,
|
||||
coverPath: coverPath,
|
||||
deleteOriginal: !isSaf, // Only delete original for regular files
|
||||
deleteOriginal: !isSaf,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
@@ -1522,15 +1506,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
||||
}
|
||||
|
||||
if (isSaf) {
|
||||
// For SAF: derive the parent tree URI and relative dir from the content URI,
|
||||
// then create new SAF file and delete old one
|
||||
// Parse the SAF URI to get the tree document path:
|
||||
// content://...tree/...document/.../oldName.flac
|
||||
// We need tree URI and relative dir to create the new file
|
||||
final uri = Uri.parse(item.filePath);
|
||||
final pathSegments = uri.pathSegments;
|
||||
|
||||
// Try to find 'tree' and 'document' segments
|
||||
String? treeUri;
|
||||
String relativeDir = '';
|
||||
String oldFileName = '';
|
||||
|
||||
+71
-37
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart';
|
||||
import 'package:spotiflac_android/services/share_intent_service.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/widgets/update_dialog.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('MainShell');
|
||||
@@ -31,9 +32,11 @@ class MainShell extends ConsumerStatefulWidget {
|
||||
ConsumerState<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends ConsumerState<MainShell> {
|
||||
class _MainShellState extends ConsumerState<MainShell>
|
||||
with SingleTickerProviderStateMixin {
|
||||
int _currentIndex = 0;
|
||||
late final PageController _pageController;
|
||||
late final AnimationController _tabJumpTransitionController;
|
||||
bool _hasCheckedUpdate = false;
|
||||
StreamSubscription<String>? _shareSubscription;
|
||||
DateTime? _lastBackPress;
|
||||
@@ -48,6 +51,11 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageController = PageController(initialPage: _currentIndex);
|
||||
_tabJumpTransitionController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
value: 1,
|
||||
);
|
||||
ShellNavigationService.syncState(
|
||||
currentTabIndex: _currentIndex,
|
||||
showStoreTab: false,
|
||||
@@ -154,7 +162,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
if (!Platform.isAndroid) return;
|
||||
|
||||
final settings = ref.read(settingsProvider);
|
||||
// Only show if user is still on legacy storage mode with a download dir set
|
||||
if (settings.storageMode == 'saf') return;
|
||||
if (settings.downloadDirectory.isEmpty) return;
|
||||
|
||||
@@ -229,6 +236,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void dispose() {
|
||||
_shareSubscription?.cancel();
|
||||
_pageController.dispose();
|
||||
_tabJumpTransitionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -251,7 +259,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
}
|
||||
|
||||
if (_currentIndex != index) {
|
||||
final shouldResetHome = index == 0;
|
||||
final previousIndex = _currentIndex;
|
||||
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
@@ -262,19 +271,23 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (shouldResetHome) {
|
||||
_resetHomeToMain();
|
||||
// Jump directly when skipping intermediate tabs to avoid
|
||||
// sliding through them. For those jumps, keep a short fade-in
|
||||
// so the transition still feels intentional.
|
||||
if (isNonAdjacentJump) {
|
||||
_pageController.jumpToPage(index);
|
||||
_tabJumpTransitionController.forward(from: 0);
|
||||
} else {
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
_pageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPageChanged(int index) {
|
||||
final previousIndex = _currentIndex;
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
final showStore = ref.read(
|
||||
@@ -285,9 +298,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
showStoreTab: showStore,
|
||||
);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (index == 0 && previousIndex != 0) {
|
||||
_resetHomeToMain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,32 +461,44 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
label: l10n.navHome,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.library_music_outlined),
|
||||
),
|
||||
selectedIcon: SlidingIcon(
|
||||
icon: AnimatedBadge(
|
||||
count: queueState,
|
||||
child: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.library_music),
|
||||
child: const Icon(Icons.library_music_outlined),
|
||||
),
|
||||
),
|
||||
selectedIcon: SlidingIcon(
|
||||
child: AnimatedBadge(
|
||||
count: queueState,
|
||||
child: Badge(
|
||||
isLabelVisible: queueState > 0,
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.library_music),
|
||||
),
|
||||
),
|
||||
),
|
||||
label: l10n.navLibrary,
|
||||
),
|
||||
if (showStore)
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store_outlined),
|
||||
),
|
||||
selectedIcon: SwingIcon(
|
||||
icon: AnimatedBadge(
|
||||
count: storeUpdatesCount,
|
||||
child: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store),
|
||||
child: const Icon(Icons.store_outlined),
|
||||
),
|
||||
),
|
||||
selectedIcon: SwingIcon(
|
||||
child: AnimatedBadge(
|
||||
count: storeUpdatesCount,
|
||||
child: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store),
|
||||
),
|
||||
),
|
||||
),
|
||||
label: l10n.navStore,
|
||||
@@ -504,15 +526,27 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
return true;
|
||||
},
|
||||
child: Scaffold(
|
||||
body: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: tabs.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||
key: ValueKey('page-$index'),
|
||||
child: tabs[index],
|
||||
body: AnimatedBuilder(
|
||||
animation: _tabJumpTransitionController,
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
itemCount: tabs.length,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) => _KeepAliveTabPage(
|
||||
key: ValueKey('page-$index'),
|
||||
child: tabs[index],
|
||||
),
|
||||
),
|
||||
builder: (context, child) {
|
||||
final t = Curves.easeOutCubic.transform(
|
||||
_tabJumpTransitionController.value,
|
||||
);
|
||||
return Opacity(
|
||||
opacity: t,
|
||||
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _currentIndex.clamp(0, maxIndex),
|
||||
@@ -707,7 +741,7 @@ class _SwingIconState extends State<SwingIcon>
|
||||
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
|
||||
TweenSequenceItem(tween: Tween(begin: -0.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();
|
||||
}
|
||||
|
||||
@@ -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<Track> 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<PlaylistScreen> {
|
||||
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
|
||||
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
|
||||
|
||||
String? _recommendedDownloadService() {
|
||||
final explicit = widget.recommendedService;
|
||||
if (explicit != null && explicit.isNotEmpty) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
final playlistId = widget.playlistId;
|
||||
if (playlistId != null) {
|
||||
if (playlistId.startsWith('tidal:')) return 'tidal';
|
||||
if (playlistId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (playlistId.startsWith('deezer:')) return 'deezer';
|
||||
}
|
||||
|
||||
final source = _tracks.firstOrNull?.source;
|
||||
if (source != null && source.isNotEmpty) {
|
||||
return source;
|
||||
}
|
||||
|
||||
final trackId = _tracks.firstOrNull?.id ?? '';
|
||||
if (trackId.startsWith('tidal:')) return 'tidal';
|
||||
if (trackId.startsWith('qobuz:')) return 'qobuz';
|
||||
if (trackId.startsWith('deezer:')) return 'deezer';
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -360,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
if (_isLoading) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
padding: EdgeInsets.all(16),
|
||||
child: TrackListSkeleton(itemCount: 8),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -411,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
final track = _tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _PlaylistTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
child: StaggeredListItem(
|
||||
index: index,
|
||||
child: _PlaylistTrackItem(
|
||||
track: track,
|
||||
onDownload: () => _downloadTrack(context, track),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, childCount: _tracks.length),
|
||||
@@ -429,6 +460,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
trackName: track.name,
|
||||
artistName: track.artistName,
|
||||
coverUrl: track.coverUrl,
|
||||
recommendedService: _recommendedDownloadService(),
|
||||
onSelect: (quality, service) {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
@@ -616,7 +648,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
void _downloadTracks(BuildContext context, List<Track> tracks) {
|
||||
if (tracks.isEmpty) return;
|
||||
|
||||
// Skip already-downloaded tracks
|
||||
final historyState = ref.read(downloadHistoryProvider);
|
||||
final settings = ref.read(settingsProvider);
|
||||
final localLibState =
|
||||
@@ -663,6 +694,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||
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,
|
||||
|
||||
+924
-1076
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/utils/clickable_metadata.dart';
|
||||
|
||||
class SearchScreen extends ConsumerStatefulWidget {
|
||||
@@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
child: Text(error, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
Expanded(
|
||||
child: tracks.isEmpty
|
||||
? _buildEmptyState(colorScheme)
|
||||
: ListView.builder(
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildTrackTile(tracks[index], colorScheme),
|
||||
),
|
||||
child: AnimatedStateSwitcher(
|
||||
child: isLoading && tracks.isEmpty
|
||||
? const TrackListSkeleton(key: ValueKey('loading'))
|
||||
: tracks.isEmpty
|
||||
? _buildEmptyState(colorScheme)
|
||||
: ListView.builder(
|
||||
key: const ValueKey('results'),
|
||||
itemCount: tracks.length,
|
||||
itemBuilder: (context, index) => StaggeredListItem(
|
||||
index: index,
|
||||
child: _buildTrackTile(tracks[index], colorScheme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -127,32 +135,30 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
}
|
||||
|
||||
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
|
||||
return ListTile(
|
||||
leading: track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 144,
|
||||
memCacheHeight: 144,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
final coverWidget = track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 144,
|
||||
memCacheHeight: 144,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
);
|
||||
return ListTile(
|
||||
leading: coverWidget,
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -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 = <int>{1211573191, 1003219236};
|
||||
|
||||
// Diamond tier supporters ($50+ donors).
|
||||
const _dv = <int>{560908930};
|
||||
|
||||
enum _SupporterTier { normal, gold, diamond }
|
||||
|
||||
_SupporterTier _tierOf(String name) {
|
||||
final h = _cr(name);
|
||||
if (_dv.contains(h)) return _SupporterTier.diamond;
|
||||
if (_cv.contains(h)) return _SupporterTier.gold;
|
||||
return _SupporterTier.normal;
|
||||
}
|
||||
|
||||
class _SupporterChip extends StatefulWidget {
|
||||
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 {
|
||||
|
||||
@@ -465,34 +465,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
),
|
||||
),
|
||||
],
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
subtitle:
|
||||
'${settings.youtubeOpusBitrate}kbps (128/256/320)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeOpusBitrateTitle,
|
||||
currentValue: settings.youtubeOpusBitrate,
|
||||
options: const [128, 256, 320],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeOpusBitrate(value),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
title: context.l10n.youtubeMp3BitrateTitle,
|
||||
subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)',
|
||||
onTap: () => _showYoutubeBitratePicker(
|
||||
context: context,
|
||||
title: context.l10n.youtubeMp3BitrateTitle,
|
||||
currentValue: settings.youtubeMp3Bitrate,
|
||||
options: const [128, 256, 320],
|
||||
onSave: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setYoutubeMp3Bitrate(value),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -869,6 +841,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
return 'Albums/[Year] Album/';
|
||||
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/';
|
||||
}
|
||||
@@ -958,6 +932,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_outline_outlined),
|
||||
title: Text(context.l10n.albumFolderArtistAlbumFlat),
|
||||
subtitle: Text(context.l10n.albumFolderArtistAlbumFlatSubtitle),
|
||||
trailing: current == 'artist_album_flat'
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () {
|
||||
ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAlbumFolderStructure('artist_album_flat');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1689,68 +1677,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showYoutubeBitratePicker({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required int currentValue,
|
||||
required List<int> options,
|
||||
required void Function(int value) onSave,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(sheetContext).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final bitrate in options)
|
||||
ListTile(
|
||||
title: Text('$bitrate kbps'),
|
||||
trailing: bitrate == currentValue
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
onSave(bitrate);
|
||||
Navigator.pop(sheetContext);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMusixmatchLanguagePicker(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
@@ -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) ...[
|
||||
|
||||
@@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
final hasError = extension.status == 'error';
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
|
||||
@@ -61,7 +61,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -600,14 +600,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 {
|
||||
|
||||
@@ -23,21 +23,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
int _androidSdkVersion = 0;
|
||||
bool _hasStoragePermission = false;
|
||||
|
||||
/// Convert SAF content URI to a readable display path
|
||||
String _getDisplayPath(String path) {
|
||||
if (!path.startsWith('content://')) return path;
|
||||
// Extract the path portion from SAF tree URI
|
||||
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
|
||||
// -> /storage/emulated/0/Music
|
||||
try {
|
||||
final uri = Uri.parse(path);
|
||||
final treePath =
|
||||
uri.pathSegments.last; // 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;
|
||||
|
||||
@@ -136,7 +136,7 @@ class _LogScreenState extends State<LogScreen> {
|
||||
final logs = _filteredLogs;
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,14 +441,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
|
||||
void _nextPage() {
|
||||
bool canProceed = false;
|
||||
// Step 0 is Welcome, always can proceed
|
||||
if (_currentStep == 0) {
|
||||
canProceed = true;
|
||||
} else {
|
||||
// Logic for other steps (offset by 1 because of welcome step)
|
||||
// Step 1: Storage
|
||||
// Step 2: Notification (if android 13+) OR Directory
|
||||
// etc.
|
||||
canProceed = _isStepCompleted(_currentStep);
|
||||
}
|
||||
|
||||
@@ -470,9 +465,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
+84
-26
@@ -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<StoreTab> {
|
||||
final downloadingId = ref.watch(
|
||||
storeProvider.select((s) => s.downloadingId),
|
||||
);
|
||||
final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl));
|
||||
final hasRegistryUrl = ref.watch(
|
||||
storeProvider.select((s) => s.hasRegistryUrl),
|
||||
);
|
||||
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
|
||||
final filteredExtensions = StoreState(
|
||||
extensions: extensions,
|
||||
@@ -139,7 +142,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
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<StoreTab> {
|
||||
: 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<StoreTab> {
|
||||
_CategoryChip(
|
||||
label: context.l10n.storeFilterIntegration,
|
||||
icon: Icons.link,
|
||||
isSelected: selectedCategory == StoreCategory.integration,
|
||||
isSelected:
|
||||
selectedCategory == StoreCategory.integration,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.integration),
|
||||
@@ -242,8 +260,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
),
|
||||
|
||||
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<StoreTab> {
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.storeAddRepoTitle,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
@@ -322,16 +343,23 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
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<StoreTab> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 20,
|
||||
color: colorScheme.onErrorContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -416,7 +448,31 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
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<StoreTab> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions,
|
||||
hasFilters
|
||||
? context.l10n.storeEmptyNoResults
|
||||
: context.l10n.storeEmptyNoExtensions,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
||||
@@ -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<TrackMetadataScreen> {
|
||||
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<String, dynamic>? _editedMetadata; // Overrides after metadata edit
|
||||
Map<String, dynamic>? _editedMetadata;
|
||||
String? _embeddedCoverPreviewPath;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
static final RegExp _lrcTimestampPattern = RegExp(
|
||||
@@ -307,12 +308,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
storedQuality: _quality,
|
||||
);
|
||||
|
||||
// Fill in album name from file tags if stored value is empty
|
||||
final needsAlbum =
|
||||
resolvedAlbum != null &&
|
||||
resolvedAlbum.isNotEmpty &&
|
||||
(albumName.isEmpty);
|
||||
// Fill in duration from file if stored value is missing/zero
|
||||
final needsDuration =
|
||||
resolvedDuration != null &&
|
||||
resolvedDuration > 0 &&
|
||||
@@ -519,6 +518,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
String get _filePath =>
|
||||
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
|
||||
String get _coverHeroTag =>
|
||||
_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId';
|
||||
String? get _coverUrl =>
|
||||
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
|
||||
String? get _localCoverPath =>
|
||||
@@ -527,7 +528,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
|
||||
DateTime get _addedAt {
|
||||
if (_isLocalItem) {
|
||||
// Use file modification time if available, otherwise fall back to scannedAt
|
||||
final modTime = _localLibraryItem!.fileModTime;
|
||||
if (modTime != null && modTime > 0) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(modTime);
|
||||
@@ -577,7 +577,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String get cleanFilePath {
|
||||
var path = _filePath;
|
||||
if (path.startsWith('EXISTS:')) path = path.substring(7);
|
||||
// Strip CUE virtual path suffix for filesystem operations
|
||||
if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path);
|
||||
return path;
|
||||
}
|
||||
@@ -770,6 +769,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
_buildLyricsCard(context, colorScheme),
|
||||
|
||||
if (_fileExists) ...[
|
||||
const SizedBox(height: 16),
|
||||
AudioAnalysisCard(filePath: _filePath),
|
||||
],
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
_buildActionButtons(context, ref, colorScheme, _fileExists),
|
||||
@@ -790,38 +794,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
|
||||
setState(() => _isEmbedding = true);
|
||||
|
||||
// Capture l10n strings before async gaps to avoid use_build_context_synchronously
|
||||
final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage;
|
||||
final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics;
|
||||
final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat;
|
||||
@@ -1986,7 +1990,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write temp file to SAF tree
|
||||
final treeUri = _downloadItem?.downloadTreeUri;
|
||||
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
||||
if (treeUri != null && treeUri.isNotEmpty) {
|
||||
@@ -2033,7 +2036,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular file path
|
||||
final dir = _getFileDirectory();
|
||||
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
|
||||
|
||||
@@ -2126,7 +2128,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Write temp file to SAF tree
|
||||
final treeUri = _downloadItem?.downloadTreeUri;
|
||||
final relativeDir = _downloadItem?.safRelativeDir ?? '';
|
||||
if (treeUri != null && treeUri.isNotEmpty) {
|
||||
@@ -2182,7 +2183,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular file path
|
||||
final dir = _getFileDirectory();
|
||||
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
|
||||
|
||||
@@ -2257,7 +2257,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final result = await PlatformBridge.reEnrichFile(request);
|
||||
final method = result['method'] as String?;
|
||||
|
||||
// Update local UI state with enriched metadata from online search
|
||||
final enriched = result['enriched_metadata'] as Map<String, dynamic>?;
|
||||
if (enriched != null && mounted) {
|
||||
setState(() {
|
||||
@@ -2344,7 +2343,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// For SAF files, copy processed temp file back
|
||||
if (ffmpegResult != null && tempPath != null && safUri != null) {
|
||||
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
|
||||
if (!ok && mounted) {
|
||||
@@ -2357,7 +2355,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
);
|
||||
// Cleanup temp files
|
||||
if (_hasPath(downloadedCoverPath)) {
|
||||
try {
|
||||
await File(downloadedCoverPath!).delete();
|
||||
@@ -2375,7 +2372,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup temp files
|
||||
if (tempPath != null && tempPath.isNotEmpty) {
|
||||
try {
|
||||
await File(tempPath).delete();
|
||||
@@ -2397,7 +2393,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup temp cover from Go backend
|
||||
if (_hasPath(downloadedCoverPath)) {
|
||||
try {
|
||||
await File(downloadedCoverPath!).delete();
|
||||
@@ -2462,7 +2457,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
for (final line in lines) {
|
||||
var cleaned = line.trim();
|
||||
|
||||
// Skip metadata tags
|
||||
if (_lrcMetadataPattern.hasMatch(cleaned) &&
|
||||
!_lrcBackgroundLinePattern.hasMatch(cleaned)) {
|
||||
continue;
|
||||
@@ -2474,7 +2468,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
cleaned = bgMatch.group(1)?.trim() ?? '';
|
||||
}
|
||||
|
||||
// Remove line timestamp, inline word-by-word timestamps, and speaker prefix.
|
||||
cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim();
|
||||
cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, '');
|
||||
cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, '');
|
||||
@@ -2685,11 +2678,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
/// Whether the current file is a CUE sheet (or CUE-referenced)
|
||||
bool get _isCueFile {
|
||||
// Check if the raw path has a CUE virtual path suffix
|
||||
if (isCueVirtualPath(rawFilePath)) return true;
|
||||
final lower = cleanFilePath.toLowerCase();
|
||||
if (lower.endsWith('.cue')) return true;
|
||||
// Check if local library item has cue+ format
|
||||
if (_isLocalItem && _localLibraryItem != null) {
|
||||
final format = _localLibraryItem!.format ?? '';
|
||||
if (format.startsWith('cue+')) return true;
|
||||
@@ -2815,7 +2806,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
final currentFormat = _currentFileFormat;
|
||||
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
|
||||
|
||||
// Build available target formats based on source
|
||||
final formats = <String>[];
|
||||
if (currentFormat == 'FLAC') {
|
||||
formats.addAll(['ALAC', 'MP3', 'Opus']);
|
||||
@@ -2906,7 +2896,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
// Only show bitrate for lossy targets
|
||||
if (!isLosslessTarget) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
@@ -2933,7 +2922,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
],
|
||||
|
||||
// Show lossless indicator
|
||||
if (isLosslessTarget && isLosslessSource) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -2991,14 +2979,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
void _showCueSplitSheet(BuildContext context) async {
|
||||
// Strip the #trackNN suffix from virtual CUE paths to get the real .cue path
|
||||
var cuePath = cleanFilePath;
|
||||
final trackSuffix = RegExp(r'#track\d+$');
|
||||
if (trackSuffix.hasMatch(cuePath)) {
|
||||
cuePath = cuePath.replaceFirst(trackSuffix, '');
|
||||
}
|
||||
|
||||
// Show loading indicator
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)),
|
||||
);
|
||||
@@ -3093,7 +3079,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Track list preview (scrollable, max 200px)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ListView.builder(
|
||||
@@ -3315,7 +3300,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
workingAudioPath = tempPath;
|
||||
}
|
||||
|
||||
// Determine output directory
|
||||
final String outputDir;
|
||||
final treeUri = !_isLocalItem
|
||||
? (_downloadItem?.downloadTreeUri ?? '')
|
||||
@@ -3342,7 +3326,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (!mounted) return;
|
||||
_showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length));
|
||||
|
||||
// Extract cover from audio file for embedding
|
||||
String? coverPath;
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
@@ -3385,11 +3368,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
for (final path in finalOutputPaths) {
|
||||
if (path.toLowerCase().endsWith('.flac')) {
|
||||
try {
|
||||
// Read existing metadata first
|
||||
final metadata = await PlatformBridge.readFileMetadata(path);
|
||||
if (metadata['error'] == null) {
|
||||
final fields = <String, String>{'cover_path': coverPath};
|
||||
// Preserve existing fields
|
||||
for (final entry in metadata.entries) {
|
||||
if (entry.key == 'error' || entry.value == null) continue;
|
||||
final v = entry.value.toString().trim();
|
||||
@@ -3415,7 +3396,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
finalOutputPaths = exportedUris;
|
||||
}
|
||||
|
||||
// Cleanup cover temp
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
await File(coverPath).delete();
|
||||
@@ -3437,7 +3417,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_showSnackBarMessage(_l10nCueSplitFailed);
|
||||
}
|
||||
} finally {
|
||||
// Cleanup SAF temp audio copy
|
||||
if (safTempAudioPath != null) {
|
||||
try {
|
||||
await File(safTempAudioPath).delete();
|
||||
@@ -3556,7 +3535,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String? safTempPath;
|
||||
|
||||
if (isSaf) {
|
||||
// Copy SAF file to temp for processing
|
||||
safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath);
|
||||
if (safTempPath == null) {
|
||||
if (mounted) {
|
||||
@@ -3576,10 +3554,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
if (newPath == null) {
|
||||
// Cleanup SAF temp if needed
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
@@ -3649,7 +3625,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default: // mp3
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
@@ -3689,7 +3665,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_log.w('Converted SAF file created but failed deleting original URI');
|
||||
}
|
||||
|
||||
// Update history with new SAF info
|
||||
if (!_isLocalItem) {
|
||||
await HistoryDatabase.instance.updateFilePath(
|
||||
_downloadItem!.id,
|
||||
@@ -3701,7 +3676,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
}
|
||||
|
||||
// Cleanup temp files
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
@@ -3711,7 +3685,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
} 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<TrackMetadataScreen> {
|
||||
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<TrackMetadataScreen> {
|
||||
WidgetRef ref,
|
||||
ColorScheme colorScheme,
|
||||
) async {
|
||||
// Read current metadata from file, fall back to item data on failure
|
||||
Map<String, dynamic>? fileMetadata;
|
||||
try {
|
||||
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
@@ -3759,7 +3730,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
debugPrint('readFileMetadata failed, using item data: $e');
|
||||
}
|
||||
|
||||
// Build initial values map — prefer file metadata, fall back to item data
|
||||
String val(String key, String? fallback) {
|
||||
final v = fileMetadata?[key]?.toString();
|
||||
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
|
||||
@@ -3805,7 +3775,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
ScaffoldMessenger.of(this.context).showSnackBar(
|
||||
SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)),
|
||||
);
|
||||
// Re-read metadata from file to refresh the display
|
||||
try {
|
||||
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
|
||||
setState(() => _editedMetadata = refreshed);
|
||||
@@ -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<String> _autoFillFields = {};
|
||||
|
||||
// All auto-fillable fields and their mapping
|
||||
static const _fieldDefs = <String, String>{
|
||||
'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 = <String, String>{
|
||||
'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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = <String>[];
|
||||
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);
|
||||
|
||||
@@ -328,6 +328,20 @@ class HistoryDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'history',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
/// Get all history items ordered by download date (newest first)
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
@@ -532,6 +546,29 @@ class HistoryDatabase {
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getEntriesWithPathsPage({
|
||||
required int limit,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'history',
|
||||
columns: [
|
||||
'id',
|
||||
'file_path',
|
||||
'storage_mode',
|
||||
'download_tree_uri',
|
||||
'saf_relative_dir',
|
||||
'saf_file_name',
|
||||
],
|
||||
where: 'file_path IS NOT NULL AND file_path != ""',
|
||||
orderBy: 'downloaded_at DESC, id DESC',
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||
}
|
||||
|
||||
/// Delete multiple entries by IDs
|
||||
Future<int> deleteByIds(List<String> ids) async {
|
||||
if (ids.isEmpty) return 0;
|
||||
|
||||
@@ -255,20 +255,41 @@ class LibraryDatabase {
|
||||
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
|
||||
if (items.isEmpty) return;
|
||||
final db = await database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'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<void> replaceAll(List<Map<String, dynamic>> items) async {
|
||||
final db = await database;
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('library');
|
||||
if (items.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final batch = txn.batch();
|
||||
for (final json in items) {
|
||||
batch.insert(
|
||||
'library',
|
||||
_jsonToDbRow(json),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit(noResult: true);
|
||||
});
|
||||
_log.i('Replaced library with ${items.length} items');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
|
||||
@@ -83,24 +83,18 @@ class PlatformBridge {
|
||||
|
||||
static Future<Map<String, dynamic>> getDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return _decodeMapResult(result);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> getAllDownloadProgress() async {
|
||||
final result = await _channel.invokeMethod('getAllDownloadProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return _decodeMapResult(result);
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> downloadProgressStream() {
|
||||
return _downloadProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
return _downloadProgressEvents.receiveBroadcastStream().map(
|
||||
_decodeMapResult,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> exitApp() async {
|
||||
@@ -1087,7 +1081,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the directory for caching extracted cover art
|
||||
static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
|
||||
_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<List<Map<String, dynamic>>> scanLibraryFolder(
|
||||
String folderPath,
|
||||
) async {
|
||||
@@ -1108,10 +1099,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Perform an incremental scan of the library folder
|
||||
/// Only scans files that are new or have changed since last scan
|
||||
/// [existingFiles] is a map of filePath -> modTime (unix millis)
|
||||
/// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
|
||||
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
|
||||
String folderPath,
|
||||
Map<String, int> existingFiles,
|
||||
@@ -1146,8 +1133,6 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Incremental SAF tree scan - only scans new or modified files
|
||||
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
|
||||
static Future<Map<String, dynamic>> scanSafTreeIncremental(
|
||||
String treeUri,
|
||||
Map<String, int> existingFiles,
|
||||
@@ -1173,8 +1158,6 @@ class PlatformBridge {
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get last-modified timestamps for a list of SAF file URIs.
|
||||
/// Returns map uri -> modTime (unix millis), only for files that still exist.
|
||||
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
|
||||
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<Map<String, dynamic>> getLibraryScanProgress() async {
|
||||
final result = await _channel.invokeMethod('getLibraryScanProgress');
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
return _decodeMapResult(result);
|
||||
}
|
||||
|
||||
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
|
||||
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) {
|
||||
if (event is String) {
|
||||
return jsonDecode(event) as Map<String, dynamic>;
|
||||
}
|
||||
if (event is Map) {
|
||||
return Map<String, dynamic>.from(event);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
});
|
||||
return _libraryScanProgressEvents.receiveBroadcastStream().map(
|
||||
_decodeMapResult,
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
static Future<void> cancelLibraryScan() async {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _decodeMapResult(dynamic result) {
|
||||
if (result is Map) {
|
||||
return Map<String, dynamic>.from(result);
|
||||
}
|
||||
if (result is String) {
|
||||
if (result.isEmpty) return const <String, dynamic>{};
|
||||
final decoded = jsonDecode(result);
|
||||
if (decoded is Map) {
|
||||
return Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
|
||||
// MARK: - iOS Security-Scoped Bookmark
|
||||
|
||||
/// Create a security-scoped bookmark from a filesystem path picked by
|
||||
@@ -1247,7 +1236,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read metadata from a single audio file
|
||||
static Future<Map<String, dynamic>?> readAudioMetadata(
|
||||
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<Map<String, dynamic>> parseCueSheet(
|
||||
String cuePath, {
|
||||
String audioDir = '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user