mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-31 20:01:39 +02:00
chore: clean up codebase
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -130,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.
|
||||
@@ -174,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")
|
||||
@@ -182,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")
|
||||
@@ -190,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")
|
||||
@@ -203,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)) {
|
||||
@@ -228,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) {
|
||||
""
|
||||
@@ -632,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()) {
|
||||
@@ -643,7 +629,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// Try MIME_TYPE
|
||||
try {
|
||||
val mime = contentResolver.getType(uri)
|
||||
val ext = extFromMimeType(mime)
|
||||
@@ -869,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)
|
||||
@@ -885,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")
|
||||
|
||||
@@ -908,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://") &&
|
||||
@@ -957,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
|
||||
|
||||
@@ -990,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("\\")
|
||||
}
|
||||
}
|
||||
@@ -1089,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>>()
|
||||
@@ -1174,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) {
|
||||
@@ -1213,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)
|
||||
@@ -1230,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) {
|
||||
@@ -1273,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
|
||||
@@ -1359,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)
|
||||
@@ -1378,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) {
|
||||
@@ -1400,7 +1359,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all files with lastModified
|
||||
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
|
||||
queue.add(root to "")
|
||||
|
||||
@@ -1456,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)
|
||||
|
||||
@@ -1469,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")) {
|
||||
@@ -1491,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))
|
||||
}
|
||||
@@ -1508,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
|
||||
@@ -1536,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) {
|
||||
@@ -1557,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++
|
||||
@@ -1566,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,
|
||||
@@ -1584,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)
|
||||
@@ -1601,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) {
|
||||
@@ -1609,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,
|
||||
@@ -1621,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)
|
||||
@@ -1654,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 {
|
||||
@@ -1681,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 }
|
||||
@@ -1694,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
|
||||
@@ -1748,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 {
|
||||
@@ -1926,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)
|
||||
}
|
||||
|
||||
@@ -2586,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)
|
||||
@@ -2664,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
|
||||
@@ -2675,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
|
||||
@@ -2686,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
|
||||
@@ -2816,7 +2747,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Log methods
|
||||
"getLogs" -> {
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getLogs()
|
||||
@@ -2849,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") ?: ""
|
||||
@@ -2994,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) {
|
||||
@@ -3044,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) {
|
||||
@@ -3072,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") ?: ""
|
||||
@@ -3088,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) {
|
||||
@@ -3133,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") ?: ""
|
||||
@@ -3177,7 +3101,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Store
|
||||
"initExtensionStore" -> {
|
||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -3239,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) {
|
||||
@@ -3254,7 +3176,6 @@ class MainActivity: FlutterFragmentActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Local Library Scanning
|
||||
"setLibraryCoverCacheDir" -> {
|
||||
val cacheDir = call.argument<String>("cache_dir") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -3359,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") ?: ""
|
||||
@@ -3371,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 ?: ""
|
||||
@@ -3400,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
|
||||
@@ -3415,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 {
|
||||
|
||||
@@ -42,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)")
|
||||
}
|
||||
@@ -88,22 +87,18 @@ 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)
|
||||
}
|
||||
|
||||
// Tidal CDN upgrade: 1280x1280 → origin
|
||||
if strings.Contains(coverURL, "resources.tidal.com") {
|
||||
return upgradeTidalCover(coverURL)
|
||||
}
|
||||
|
||||
// Qobuz CDN upgrade: _600 → _max
|
||||
if strings.Contains(coverURL, "static.qobuz.com") {
|
||||
return upgradeQobuzCover(coverURL)
|
||||
}
|
||||
@@ -152,7 +147,6 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1906,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",
|
||||
@@ -1989,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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -396,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.
|
||||
@@ -500,7 +498,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto
|
||||
!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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -874,10 +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',
|
||||
@@ -888,9 +884,6 @@ 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 {
|
||||
final dotIndex = originalPath.lastIndexOf('.');
|
||||
if (dotIndex < 0) return null;
|
||||
@@ -2711,7 +2704,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';
|
||||
@@ -2724,7 +2716,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,
|
||||
@@ -3405,7 +3396,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);
|
||||
@@ -3465,7 +3455,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',
|
||||
);
|
||||
@@ -3487,7 +3476,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,
|
||||
@@ -3496,16 +3484,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(
|
||||
@@ -3639,8 +3623,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),
|
||||
@@ -3926,7 +3908,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
if (resolvedIsrc != null && _isValidISRC(resolvedIsrc)) {
|
||||
_log.d('Resolved ISRC from $provider: $resolvedIsrc');
|
||||
|
||||
// Enrich track with provider metadata
|
||||
final provReleaseDate = normalizeOptionalString(
|
||||
trackData['release_date'] as String?,
|
||||
);
|
||||
@@ -3962,7 +3943,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
|
||||
// Search Deezer by the resolved ISRC
|
||||
try {
|
||||
final deezerResult = await PlatformBridge.searchDeezerByISRC(
|
||||
resolvedIsrc,
|
||||
@@ -3988,9 +3968,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
|
||||
// Skip for tidal:/qobuz: IDs – they are not Spotify URLs and the
|
||||
// provider ISRC resolution above already handles them.
|
||||
if (!selectedExtensionDownloadProvider &&
|
||||
deezerTrackId == null &&
|
||||
!shouldSkipExtensionSongLinkPrelookup &&
|
||||
@@ -4011,7 +3988,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?;
|
||||
@@ -4317,7 +4293,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');
|
||||
@@ -4330,7 +4305,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,
|
||||
@@ -4486,7 +4460,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) {
|
||||
@@ -4979,9 +4952,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 &&
|
||||
@@ -5296,8 +5266,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) {
|
||||
@@ -5350,7 +5318,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,7 +318,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Skipped $skippedDownloads files already in download history');
|
||||
}
|
||||
|
||||
// Full scan should replace library index atomically.
|
||||
await _db.replaceAll(items.map((e) => e.toJson()).toList());
|
||||
final persistedItems = [...items]..sort(_compareLibraryItems);
|
||||
|
||||
@@ -357,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',
|
||||
@@ -416,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>?) ??
|
||||
@@ -437,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))
|
||||
@@ -461,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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -264,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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1105,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;
|
||||
|
||||
@@ -1367,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';
|
||||
@@ -1488,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) {
|
||||
@@ -1507,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 = '';
|
||||
|
||||
@@ -162,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;
|
||||
|
||||
|
||||
@@ -97,7 +97,6 @@ class UnifiedLibraryItem {
|
||||
} else if (item.bitDepth != null &&
|
||||
item.bitDepth! > 0 &&
|
||||
item.sampleRate != null) {
|
||||
// Lossless format with actual bit depth
|
||||
quality = buildDisplayAudioQuality(
|
||||
bitDepth: item.bitDepth,
|
||||
sampleRate: item.sampleRate,
|
||||
@@ -108,7 +107,7 @@ class UnifiedLibraryItem {
|
||||
trackName: item.trackName,
|
||||
artistName: item.artistName,
|
||||
albumName: item.albumName,
|
||||
coverUrl: null, // Local library doesn't have cover URLs
|
||||
coverUrl: null,
|
||||
localCoverPath: item.coverPath,
|
||||
filePath: item.filePath,
|
||||
quality: quality,
|
||||
@@ -170,9 +169,6 @@ class UnifiedLibraryItem {
|
||||
}
|
||||
if (localItem != null) {
|
||||
final l = localItem!;
|
||||
// Store coverPath (even local file paths) in coverUrl so playlist
|
||||
// entries retain the cover. All renderers must check whether the
|
||||
// value is a URL or a local path and use the appropriate widget.
|
||||
return Track(
|
||||
id: l.id,
|
||||
name: l.trackName,
|
||||
@@ -188,7 +184,6 @@ class UnifiedLibraryItem {
|
||||
source: 'local',
|
||||
);
|
||||
}
|
||||
// Fallback — should not happen
|
||||
return Track(
|
||||
id: id,
|
||||
name: trackName,
|
||||
@@ -4889,15 +4884,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
for (final id in _selectedIds) {
|
||||
final item = itemsById[id];
|
||||
if (item == null) continue;
|
||||
// Detect format: use safFileName for download history SAF items,
|
||||
// item.localItem?.format for local library items, file extension as fallback
|
||||
String nameToCheck;
|
||||
if (item.historyItem?.safFileName != null &&
|
||||
item.historyItem!.safFileName!.isNotEmpty) {
|
||||
nameToCheck = item.historyItem!.safFileName!.toLowerCase();
|
||||
} else if (item.localItem?.format != null &&
|
||||
item.localItem!.format!.isNotEmpty) {
|
||||
// Synthesize a fake extension to keep detection unified
|
||||
nameToCheck = '.${item.localItem!.format!.toLowerCase()}';
|
||||
} else {
|
||||
nameToCheck = item.filePath.toLowerCase();
|
||||
@@ -4912,7 +4904,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
? '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;
|
||||
@@ -5060,7 +5051,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle SAF write-back
|
||||
if (isSaf && item.historyItem != null) {
|
||||
final hi = item.historyItem!;
|
||||
final treeUri = hi.downloadTreeUri;
|
||||
@@ -5113,12 +5103,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete old SAF file
|
||||
try {
|
||||
await PlatformBridge.safDelete(item.filePath);
|
||||
} catch (_) {}
|
||||
|
||||
// Update history
|
||||
await historyDb.updateFilePath(
|
||||
hi.id,
|
||||
safUri,
|
||||
@@ -5127,7 +5115,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
}
|
||||
// Cleanup temp files
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
@@ -5137,7 +5124,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (isSaf && item.localItem != null) {
|
||||
// Local library SAF item: parse content URI to derive tree and dir
|
||||
final uri = Uri.parse(item.filePath);
|
||||
final pathSegments = uri.pathSegments;
|
||||
|
||||
@@ -5214,7 +5200,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||
}
|
||||
|
||||
// Cleanup temp files
|
||||
try {
|
||||
await File(newPath).delete();
|
||||
} catch (_) {}
|
||||
@@ -5224,7 +5209,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
} catch (_) {}
|
||||
}
|
||||
} else if (item.historyItem != null) {
|
||||
// Regular file - update history path
|
||||
await historyDb.updateFilePath(
|
||||
item.historyItem!.id,
|
||||
newPath,
|
||||
@@ -5232,17 +5216,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
clearAudioSpecs: true,
|
||||
);
|
||||
} else if (item.localItem != null) {
|
||||
// Regular local library file - delete old db entry, rescan picks up new file
|
||||
await LibraryDatabase.instance.deleteByPath(item.filePath);
|
||||
}
|
||||
|
||||
successCount++;
|
||||
} catch (_) {
|
||||
// Continue to next item on error
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Reload history and local library to reflect path changes in UI
|
||||
ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
|
||||
ref.read(localLibraryProvider.notifier).reloadFromStorage();
|
||||
|
||||
@@ -5264,7 +5244,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom action bar for selection mode (Material Design 3 style)
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -60,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(
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1707,7 +1706,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
_spotifyId ?? '',
|
||||
trackName,
|
||||
artistName,
|
||||
filePath: null, // Don't check file again
|
||||
filePath: null,
|
||||
durationMs: durationMs,
|
||||
).timeout(const Duration(seconds: 20));
|
||||
|
||||
@@ -1733,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;
|
||||
});
|
||||
}
|
||||
@@ -1762,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;
|
||||
@@ -3556,7 +3554,7 @@ 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,
|
||||
);
|
||||
|
||||
if (coverPath != null) {
|
||||
@@ -3627,7 +3625,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
newExt = '.flac';
|
||||
mimeType = 'audio/flac';
|
||||
break;
|
||||
default: // mp3
|
||||
default:
|
||||
newExt = '.mp3';
|
||||
mimeType = 'audio/mpeg';
|
||||
break;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1081,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', {
|
||||
@@ -1089,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 {
|
||||
@@ -1102,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,
|
||||
@@ -1140,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,
|
||||
@@ -1167,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),
|
||||
@@ -1177,7 +1166,6 @@ 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 _decodeMapResult(result);
|
||||
@@ -1189,7 +1177,6 @@ class PlatformBridge {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cancel ongoing library scan
|
||||
static Future<void> cancelLibraryScan() async {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
}
|
||||
@@ -1249,7 +1236,6 @@ class PlatformBridge {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read metadata from a single audio file
|
||||
static Future<Map<String, dynamic>?> readAudioMetadata(
|
||||
String filePath,
|
||||
) async {
|
||||
@@ -1369,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,
|
||||
|
||||
@@ -27,7 +27,6 @@ Future<void> navigateToArtist(
|
||||
|
||||
final normalizedArtistId = _normalizeArtistId(artistId);
|
||||
|
||||
// If we have a valid artist ID already, navigate directly
|
||||
if (normalizedArtistId != null &&
|
||||
_canNavigateArtistDirectly(
|
||||
artistId: normalizedArtistId,
|
||||
@@ -43,7 +42,6 @@ Future<void> navigateToArtist(
|
||||
return;
|
||||
}
|
||||
|
||||
// Search Deezer to resolve the artist ID
|
||||
_showLoadingSnackBar(context, 'Looking up artist...');
|
||||
try {
|
||||
final results = await PlatformBridge.searchDeezerAll(
|
||||
@@ -60,7 +58,6 @@ Future<void> navigateToArtist(
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best match - prefer exact name match (case-insensitive)
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = artistName.toLowerCase().trim();
|
||||
for (final a in artistList) {
|
||||
@@ -113,7 +110,6 @@ Future<void> navigateToAlbum(
|
||||
}) async {
|
||||
if (albumName.isEmpty) return;
|
||||
|
||||
// If we have a valid album ID already, navigate directly
|
||||
if (albumId != null &&
|
||||
albumId.isNotEmpty &&
|
||||
albumId != 'unknown' &&
|
||||
@@ -128,16 +124,13 @@ Future<void> navigateToAlbum(
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's extension-based content without an ID, can't search Deezer for it
|
||||
if (extensionId != null) {
|
||||
_showUnavailable(context, 'Album');
|
||||
return;
|
||||
}
|
||||
|
||||
// Search Deezer to resolve the album ID
|
||||
_showLoadingSnackBar(context, 'Looking up album...');
|
||||
try {
|
||||
// Build search query: "albumName artistName" for better accuracy
|
||||
final query = artistName != null && artistName.isNotEmpty
|
||||
? '$albumName $artistName'
|
||||
: albumName;
|
||||
@@ -156,7 +149,6 @@ Future<void> navigateToAlbum(
|
||||
return;
|
||||
}
|
||||
|
||||
// Find best match - prefer exact name match (case-insensitive)
|
||||
Map<String, dynamic>? bestMatch;
|
||||
final lowerName = albumName.toLowerCase().trim();
|
||||
for (final a in albumList) {
|
||||
|
||||
@@ -14,8 +14,6 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
// Data models
|
||||
|
||||
class AudioAnalysisData {
|
||||
final String filePath;
|
||||
final int fileSize;
|
||||
@@ -98,8 +96,6 @@ class SpectrogramData {
|
||||
});
|
||||
}
|
||||
|
||||
// Audio Analysis Card Widget
|
||||
|
||||
class AudioAnalysisCard extends StatefulWidget {
|
||||
final String filePath;
|
||||
|
||||
@@ -179,7 +175,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
});
|
||||
|
||||
try {
|
||||
// Try loading from cache first
|
||||
final cached = await _loadFromCache(widget.filePath);
|
||||
AudioAnalysisData data;
|
||||
|
||||
@@ -187,7 +182,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
data = cached;
|
||||
} else {
|
||||
data = await _runAnalysis(widget.filePath);
|
||||
// Save to cache (fire-and-forget)
|
||||
_saveToCache(widget.filePath, data);
|
||||
}
|
||||
|
||||
@@ -214,8 +208,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
}
|
||||
|
||||
// Analysis cache
|
||||
|
||||
static String _cacheKey(String filePath) {
|
||||
var hash = 0xcbf29ce484222325;
|
||||
for (final byte in utf8.encode(filePath)) {
|
||||
@@ -267,8 +259,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Analysis pipeline
|
||||
|
||||
Future<AudioAnalysisData> _runAnalysis(String filePath) async {
|
||||
await FFmpegKitConfig.setLogLevel(Level.avLogError);
|
||||
|
||||
@@ -302,7 +292,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
),
|
||||
);
|
||||
|
||||
// Total samples from file metadata (not truncated PCM)
|
||||
final trueTotalSamples =
|
||||
(info.duration * info.sampleRate * info.channels).round();
|
||||
|
||||
@@ -468,7 +457,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
|
||||
// Still checking cache, show nothing yet
|
||||
if (_checkingCache) return const SizedBox.shrink();
|
||||
|
||||
if (_analyzing) {
|
||||
@@ -575,8 +563,6 @@ class _AudioAnalysisCardState extends State<AudioAnalysisCard> {
|
||||
}
|
||||
}
|
||||
|
||||
// Internal types
|
||||
|
||||
class _MediaInfo {
|
||||
final int fileSize;
|
||||
final int sampleRate;
|
||||
@@ -623,8 +609,6 @@ class _AnalysisResult {
|
||||
});
|
||||
}
|
||||
|
||||
// Isolate: PCM analysis + FFT spectrogram
|
||||
|
||||
_AnalysisResult _analyzeInIsolate(_AnalysisParams params) {
|
||||
final byteData = ByteData.sublistView(params.pcmBytes);
|
||||
final sampleCount = params.pcmBytes.length ~/ 2;
|
||||
@@ -767,8 +751,6 @@ Float64List _fft(Float64List realInput) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Audio Info Card
|
||||
|
||||
class _AudioInfoCard extends StatelessWidget {
|
||||
final AudioAnalysisData data;
|
||||
|
||||
@@ -945,8 +927,6 @@ class _MetricChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// Spectrogram View
|
||||
|
||||
class _SpectrogramView extends StatelessWidget {
|
||||
final ui.Image image;
|
||||
final SpectrogramData spectrum;
|
||||
@@ -1011,8 +991,6 @@ class _ImagePainter extends CustomPainter {
|
||||
bool shouldRepaint(covariant _ImagePainter old) => old.image != image;
|
||||
}
|
||||
|
||||
// Spectrogram pixel-buffer rendering (runs in isolate)
|
||||
|
||||
class _SpectrogramRenderParams {
|
||||
final SpectrogramData spectrum;
|
||||
final int width;
|
||||
@@ -1031,7 +1009,6 @@ Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) {
|
||||
final spectrum = params.spectrum;
|
||||
final pixels = Uint8List(w * h * 4);
|
||||
|
||||
// Fill black
|
||||
for (int i = 3; i < pixels.length; i += 4) {
|
||||
pixels[i] = 255;
|
||||
}
|
||||
@@ -1041,7 +1018,6 @@ Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) {
|
||||
|
||||
final freqBins = spectrum.freqBins;
|
||||
|
||||
// dB range
|
||||
double minDB = 0;
|
||||
double maxDB = -200;
|
||||
for (final slice in slices) {
|
||||
|
||||
@@ -166,7 +166,6 @@ class _GitHubPainter extends CustomPainter {
|
||||
9.47 * scale, 17.93 * scale,
|
||||
9.81 * scale, 17.63 * scale,
|
||||
);
|
||||
// Bottom
|
||||
path.cubicTo(
|
||||
7.15 * scale, 17.33 * scale,
|
||||
4.34 * scale, 16.33 * scale,
|
||||
|
||||
Reference in New Issue
Block a user