From 278ebf34723b6542d5309ef74960eee0124cc969 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 6 Feb 2026 07:09:57 +0700 Subject: [PATCH] feat: add Storage Access Framework (SAF) support for Android 10+ - Add SAF tree picker and persistent URI storage in settings - Implement SAF file operations: exists, delete, stat, copy, create - Update download pipeline to support SAF content URIs - Add fallback to app-private storage when SAF write fails - Support SAF in library scan with DocumentFile traversal - Add history item repair for missing SAF URIs - Create file_access.dart utilities for abstracted file operations - Update Tidal/Qobuz/Amazon/Extensions for SAF-aware output - Add runPostProcessingV2 API for SAF content URIs - Update screens (album, artist, queue, track) for SAF awareness Resolves Android 10+ scoped storage permission issues --- CHANGELOG.md | 38 +- android/app/build.gradle.kts | 1 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 687 +++++++++- go_backend/amazon.go | 36 +- go_backend/exports.go | 62 +- go_backend/extension_providers.go | 147 ++- go_backend/qobuz.go | 34 +- go_backend/tidal.go | 118 +- ios/Runner/AppDelegate.swift | 8 + lib/models/settings.dart | 10 +- lib/models/settings.g.dart | 4 + lib/providers/download_queue_provider.dart | 1132 +++++++++++++---- lib/providers/local_library_provider.dart | 22 +- lib/providers/settings_provider.dart | 19 + lib/screens/album_screen.dart | 6 +- lib/screens/artist_screen.dart | 6 +- lib/screens/downloaded_album_screen.dart | 12 +- lib/screens/home_tab.dart | 5 +- lib/screens/local_album_screen.dart | 11 +- lib/screens/playlist_screen.dart | 6 +- lib/screens/queue_tab.dart | 95 +- .../settings/download_settings_page.dart | 28 +- .../settings/library_settings_page.dart | 50 +- lib/screens/setup_screen.dart | 44 +- lib/screens/track_metadata_screen.dart | 46 +- lib/services/history_database.dart | 27 +- lib/services/library_database.dart | 4 +- lib/services/platform_bridge.dart | 130 ++ lib/utils/file_access.dart | 66 + lib/widgets/download_service_picker.dart | 24 +- 30 files changed, 2489 insertions(+), 389 deletions(-) create mode 100644 lib/utils/file_access.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 74d7f36d..aefee703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [3.5.0] - 2026-02-06 + +### Highlights + +- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs) + - Select download folder via SAF tree picker + - Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths + - Works around Android 10+ scoped storage permission errors + +### Added + +- New settings fields for storage mode + SAF tree URI +- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF +- SAF library scan mode (DocumentFile traversal + metadata read) +- Library UI toggle to show SAF-repaired history items +- Scan cancelled banner + retry action for library scans +- Android DocumentFile dependency for SAF operations +- Post-processing API v2 (SAF-aware, ready to replace v1) + +### Changed + +- Download pipeline supports `output_path` + `output_ext` for Go backend +- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled +- Post-processing hooks run for SAF content URIs (via temp file bridge) +- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`) + +### Fixed + +- Android 10+ `permission denied` when writing to `/storage/emulated/0` (now handled via SAF) +- SAF history repair: auto-resolve missing content URIs using tree + filename +- SAF download fallback: retry in app-private storage when SAF write fails +- Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path) +- External LRC output in SAF mode + +--- + ## [3.4.2] - 2026-02-04 ### Improved @@ -522,4 +558,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int --- -*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases) \ No newline at end of file +*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aa313d35..c1d50a00 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -103,4 +103,5 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") + implementation("androidx.documentfile:documentfile:1.0.1") } diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index b701685d..40378c1d 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1,7 +1,11 @@ package com.zarz.spotiflac +import android.app.Activity import android.content.Intent +import android.net.Uri import android.os.Build +import androidx.activity.result.contract.ActivityResultContracts +import androidx.documentfile.provider.DocumentFile import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterShellArgs @@ -12,11 +16,59 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream import java.util.Locale class MainActivity: FlutterActivity() { private val CHANNEL = "com.zarz.spotiflac/backend" private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var pendingSafTreeResult: MethodChannel.Result? = null + private val safScanLock = Any() + private var safScanProgress = SafScanProgress() + @Volatile private var safScanCancel = false + @Volatile private var safScanActive = false + private val safTreeLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { activityResult -> + val result = pendingSafTreeResult ?: return@registerForActivityResult + pendingSafTreeResult = null + + if (activityResult.resultCode != Activity.RESULT_OK) { + result.success(null) + return@registerForActivityResult + } + + val data = activityResult.data + val uri = data?.data + if (uri == null) { + result.success(null) + return@registerForActivityResult + } + + val takeFlags = data.flags and (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + try { + contentResolver.takePersistableUriPermission(uri, takeFlags) + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to persist SAF permission: ${e.message}") + } + + val payload = JSONObject() + payload.put("tree_uri", uri.toString()) + result.success(payload.toString()) + } + + data class SafScanProgress( + var totalFiles: Int = 0, + var scannedFiles: Int = 0, + var currentFile: String = "", + var errorCount: Int = 0, + var progressPct: Double = 0.0, + var isComplete: Boolean = false, + ) companion object { // Minimum API level we consider "safe" for Impeller (Android 10+) @@ -149,6 +201,474 @@ class MainActivity: FlutterActivity() { } } + private fun normalizeExt(ext: String?): String { + if (ext.isNullOrBlank()) return "" + return if (ext.startsWith(".")) ext.lowercase(Locale.ROOT) else ".${ext.lowercase(Locale.ROOT)}" + } + + private fun mimeTypeForExt(ext: String?): String { + return when (normalizeExt(ext)) { + ".m4a" -> "audio/mp4" + ".mp3" -> "audio/mpeg" + ".opus" -> "audio/ogg" + ".flac" -> "audio/flac" + else -> "application/octet-stream" + } + } + + private fun sanitizeFilename(name: String): String { + return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim() + } + + private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? { + var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null + if (relativeDir.isBlank()) return current + + val parts = relativeDir.split("/").filter { it.isNotBlank() } + for (part in parts) { + val existing = current.findFile(part) + current = if (existing != null && existing.isDirectory) { + existing + } else { + current.createDirectory(part) ?: return null + } + } + return current + } + + private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? { + var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null + if (relativeDir.isBlank()) return current + + val parts = relativeDir.split("/").filter { it.isNotBlank() } + for (part in parts) { + val existing = current.findFile(part) + if (existing == null || !existing.isDirectory) return null + current = existing + } + return current + } + + private fun resetSafScanProgress() { + synchronized(safScanLock) { + safScanProgress = SafScanProgress() + } + } + + private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) { + synchronized(safScanLock) { + block(safScanProgress) + } + } + + private fun safProgressToJson(): String { + val snapshot = synchronized(safScanLock) { safScanProgress.copy() } + val obj = JSONObject() + obj.put("total_files", snapshot.totalFiles) + obj.put("scanned_files", snapshot.scannedFiles) + obj.put("current_file", snapshot.currentFile) + obj.put("error_count", snapshot.errorCount) + obj.put("progress_pct", snapshot.progressPct) + obj.put("is_complete", snapshot.isComplete) + return obj.toString() + } + + private fun resolveSafFile(treeUriStr: String, relativeDir: String, fileName: String): String { + val obj = JSONObject() + if (treeUriStr.isBlank() || fileName.isBlank()) { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + + val treeUri = Uri.parse(treeUriStr) + val targetDir = findDocumentDir(treeUri, relativeDir) + if (targetDir != null) { + val direct = targetDir.findFile(fileName) + if (direct != null && direct.isFile) { + obj.put("uri", direct.uri.toString()) + obj.put("relative_dir", relativeDir) + return obj.toString() + } + } + + val root = DocumentFile.fromTreeUri(this, treeUri) ?: run { + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + + val queue: ArrayDeque> = ArrayDeque() + queue.add(root to "") + var visited = 0 + val maxVisited = 20000 + + while (queue.isNotEmpty()) { + if (visited > maxVisited) break + val (dir, path) = queue.removeFirst() + for (child in dir.listFiles()) { + visited++ + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + queue.add(child to childPath) + } else if (child.isFile) { + if (child.name == fileName) { + obj.put("uri", child.uri.toString()) + obj.put("relative_dir", path) + return obj.toString() + } + } + } + } + + obj.put("uri", "") + obj.put("relative_dir", "") + return obj.toString() + } + + private fun buildSafFileName(req: JSONObject, outputExt: String): String { + val provided = req.optString("saf_file_name", "") + if (provided.isNotBlank()) return provided + + val trackName = req.optString("track_name", "track") + val artistName = req.optString("artist_name", "") + val baseName = if (artistName.isNotBlank()) "$artistName - $trackName" else trackName + return sanitizeFilename(baseName) + outputExt + } + + private fun errorJson(message: String): String { + val obj = JSONObject() + obj.put("success", false) + obj.put("error", message) + obj.put("message", message) + return obj.toString() + } + + private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { + val mime = contentResolver.getType(uri) + val ext = when (mime) { + "audio/mp4" -> ".m4a" + "audio/mpeg" -> ".mp3" + "audio/ogg" -> ".opus" + "audio/flac" -> ".flac" + else -> fallbackExt ?: "" + } + val suffix: String? = if (ext.isNotBlank()) ext else null + val tempFile = File.createTempFile("saf_", suffix, cacheDir) + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } ?: return null + return tempFile.absolutePath + } + + private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean { + val srcFile = File(srcPath) + if (!srcFile.exists()) return false + contentResolver.openOutputStream(uri, "wt")?.use { output -> + FileInputStream(srcFile).use { input -> + input.copyTo(output) + } + } ?: return false + return true + } + + private fun handleSafDownload(requestJson: String, downloader: (String) -> String): String { + val req = JSONObject(requestJson) + val storageMode = req.optString("storage_mode", "") + val treeUriStr = req.optString("saf_tree_uri", "") + if (storageMode != "saf" || treeUriStr.isBlank()) { + return downloader(requestJson) + } + + val treeUri = Uri.parse(treeUriStr) + val relativeDir = req.optString("saf_relative_dir", "") + val outputExt = normalizeExt(req.optString("saf_output_ext", "")) + val mimeType = mimeTypeForExt(outputExt) + val targetDir = ensureDocumentDir(treeUri, relativeDir) + ?: return errorJson("Failed to access SAF directory") + + val fileName = buildSafFileName(req, outputExt) + val existing = targetDir.findFile(fileName) + if (existing != null && existing.isFile && existing.length() > 0) { + val obj = JSONObject() + obj.put("success", true) + obj.put("message", "File already exists") + obj.put("file_path", existing.uri.toString()) + obj.put("file_name", existing.name ?: fileName) + obj.put("already_exists", true) + return obj.toString() + } + + val document = existing ?: targetDir.createFile(mimeType, fileName) + ?: return errorJson("Failed to create SAF file") + + val pfd = contentResolver.openFileDescriptor(document.uri, "rw") + ?: return errorJson("Failed to open SAF file") + + try { + req.put("output_path", "/proc/self/fd/${pfd.fd}") + req.put("output_ext", outputExt) + val response = downloader(req.toString()) + val respObj = JSONObject(response) + if (respObj.optBoolean("success", false)) { + respObj.put("file_path", document.uri.toString()) + respObj.put("file_name", document.name ?: fileName) + } else { + document.delete() + } + return respObj.toString() + } catch (e: Exception) { + document.delete() + return errorJson("SAF download failed: ${e.message}") + } finally { + try { + pfd.close() + } catch (_: Exception) {} + } + } + + private fun scanSafTree(treeUriStr: String): String { + if (treeUriStr.isBlank()) return "[]" + + val treeUri = Uri.parse(treeUriStr) + val root = DocumentFile.fromTreeUri(this, treeUri) ?: return "[]" + + resetSafScanProgress() + safScanCancel = false + safScanActive = true + + val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") + val audioFiles = mutableListOf>() + + val queue: ArrayDeque> = ArrayDeque() + queue.add(root to "") + + while (queue.isNotEmpty()) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + return "[]" + } + + val (dir, path) = queue.removeFirst() + for (child in dir.listFiles()) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + return "[]" + } + + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + queue.add(child to childPath) + } else if (child.isFile) { + val name = child.name ?: continue + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + audioFiles.add(child to path) + } + } + } + } + + updateSafScanProgress { + it.totalFiles = audioFiles.size + } + + if (audioFiles.isEmpty()) { + updateSafScanProgress { + it.isComplete = true + it.progressPct = 100.0 + } + return "[]" + } + + val results = JSONArray() + var scanned = 0 + var errors = 0 + + for ((doc, _) in audioFiles) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + return "[]" + } + + val name = doc.name ?: "" + updateSafScanProgress { + it.currentFile = name + } + + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null + val tempPath = copyUriToTemp(doc.uri, fallbackExt) + if (tempPath == null) { + errors++ + } else { + try { + val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) + if (metadataJson.isNotBlank()) { + val obj = JSONObject(metadataJson) + obj.put("filePath", doc.uri.toString()) + results.put(obj) + } else { + errors++ + } + } catch (_: Exception) { + errors++ + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } + + scanned++ + val pct = scanned.toDouble() / audioFiles.size.toDouble() * 100.0 + updateSafScanProgress { + it.scannedFiles = scanned + it.errorCount = errors + it.progressPct = pct + } + } + + updateSafScanProgress { + it.isComplete = true + it.progressPct = 100.0 + } + + return results.toString() + } + + private fun runPostProcessingSaf(fileUriStr: String, metadataJson: String): String { + val uri = Uri.parse(fileUriStr) + val doc = DocumentFile.fromSingleUri(this, uri) + ?: return errorJson("SAF file not found") + + val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp") + val tempDir = File(tempInput).parentFile?.absolutePath ?: "" + if (tempDir.isNotBlank()) { + try { + Gobackend.allowDownloadDir(tempDir) + } catch (_: Exception) {} + } + + val response = Gobackend.runPostProcessingJSON(tempInput, metadataJson) + val respObj = JSONObject(response) + if (!respObj.optBoolean("success", false)) { + try { + File(tempInput).delete() + } catch (_: Exception) {} + return response + } + + val newPath = respObj.optString("new_file_path", "") + val outputPath = if (newPath.isNotBlank()) newPath else tempInput + val outputFile = File(outputPath) + if (!outputFile.exists()) { + try { + File(tempInput).delete() + } catch (_: Exception) {} + respObj.put("success", false) + respObj.put("error", "postProcess output not found") + return respObj.toString() + } + + val newName = outputFile.name + if (!newName.isNullOrBlank() && doc.name != null && doc.name != newName) { + try { + doc.renameTo(newName) + } catch (_: Exception) {} + } + + val writeOk = writeUriFromPath(uri, outputFile.absolutePath) + if (!writeOk) { + respObj.put("success", false) + respObj.put("error", "failed to write postProcess output to SAF") + return respObj.toString() + } + + try { + if (outputPath != tempInput) { + outputFile.delete() + } + File(tempInput).delete() + } catch (_: Exception) {} + + respObj.put("new_file_path", uri.toString()) + respObj.put("file_path", uri.toString()) + return respObj.toString() + } + + private fun runPostProcessingSafV2(fileUriStr: String, metadataJson: String): String { + val uri = Uri.parse(fileUriStr) + val doc = DocumentFile.fromSingleUri(this, uri) + ?: return errorJson("SAF file not found") + + val tempInput = copyUriToTemp(uri) ?: return errorJson("Failed to copy SAF file to temp") + val tempDir = File(tempInput).parentFile?.absolutePath ?: "" + if (tempDir.isNotBlank()) { + try { + Gobackend.allowDownloadDir(tempDir) + } catch (_: Exception) {} + } + + val inputObj = JSONObject() + inputObj.put("path", tempInput) + inputObj.put("uri", fileUriStr) + inputObj.put("name", doc.name ?: File(tempInput).name) + inputObj.put("mime_type", doc.type ?: contentResolver.getType(uri) ?: "") + inputObj.put("size", doc.length()) + inputObj.put("is_saf", true) + + val response = Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadataJson) + val respObj = JSONObject(response) + if (!respObj.optBoolean("success", false)) { + try { + File(tempInput).delete() + } catch (_: Exception) {} + return response + } + + val newPath = respObj.optString("new_file_path", "") + val outputPath = if (newPath.isNotBlank()) newPath else tempInput + val outputFile = File(outputPath) + if (!outputFile.exists()) { + try { + File(tempInput).delete() + } catch (_: Exception) {} + respObj.put("success", false) + respObj.put("error", "postProcess output not found") + return respObj.toString() + } + + val newName = outputFile.name + if (!newName.isNullOrBlank() && doc.name != null && doc.name != newName) { + try { + doc.renameTo(newName) + } catch (_: Exception) {} + } + + val writeOk = writeUriFromPath(uri, outputFile.absolutePath) + if (!writeOk) { + respObj.put("success", false) + respObj.put("error", "failed to write postProcess output to SAF") + return respObj.toString() + } + + try { + if (outputPath != tempInput) { + outputFile.delete() + } + File(tempInput).delete() + } catch (_: Exception) {} + + respObj.put("new_file_path", uri.toString()) + respObj.put("file_path", uri.toString()) + return respObj.toString() + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Update the intent so receive_sharing_intent can access the new data @@ -204,14 +724,18 @@ class MainActivity: FlutterActivity() { "downloadTrack" -> { val requestJson = call.arguments as String val response = withContext(Dispatchers.IO) { - Gobackend.downloadTrack(requestJson) + handleSafDownload(requestJson) { json -> + Gobackend.downloadTrack(json) + } } result.success(response) } "downloadWithFallback" -> { val requestJson = call.arguments as String val response = withContext(Dispatchers.IO) { - Gobackend.downloadWithFallback(requestJson) + handleSafDownload(requestJson) { json -> + Gobackend.downloadWithFallback(json) + } } result.success(response) } @@ -307,6 +831,115 @@ class MainActivity: FlutterActivity() { } result.success(response) } + "pickSafTree" -> { + if (pendingSafTreeResult != null) { + result.error("saf_pending", "SAF picker already active", null) + return@launch + } + pendingSafTreeResult = result + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + intent.addFlags( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ) + safTreeLauncher.launch(intent) + } + "safExists" -> { + val uriStr = call.argument("uri") ?: "" + val exists = withContext(Dispatchers.IO) { + val uri = Uri.parse(uriStr) + DocumentFile.fromSingleUri(this@MainActivity, uri)?.exists() == true + } + result.success(exists) + } + "safDelete" -> { + val uriStr = call.argument("uri") ?: "" + val deleted = withContext(Dispatchers.IO) { + val uri = Uri.parse(uriStr) + DocumentFile.fromSingleUri(this@MainActivity, uri)?.delete() == true + } + result.success(deleted) + } + "safStat" -> { + val uriStr = call.argument("uri") ?: "" + val response = withContext(Dispatchers.IO) { + val uri = Uri.parse(uriStr) + val doc = DocumentFile.fromSingleUri(this@MainActivity, uri) + val obj = JSONObject() + if (doc != null && doc.exists()) { + obj.put("exists", true) + obj.put("size", doc.length()) + obj.put("modified", doc.lastModified()) + obj.put("mime_type", doc.type ?: contentResolver.getType(uri) ?: "") + } else { + obj.put("exists", false) + obj.put("size", 0) + obj.put("modified", 0) + obj.put("mime_type", "") + } + obj.toString() + } + result.success(response) + } + "resolveSafFile" -> { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val fileName = call.argument("file_name") ?: "" + val response = withContext(Dispatchers.IO) { + resolveSafFile(treeUriStr, relativeDir, fileName) + } + result.success(response) + } + "safCopyToTemp" -> { + val uriStr = call.argument("uri") ?: "" + val tempPath = withContext(Dispatchers.IO) { + copyUriToTemp(Uri.parse(uriStr)) + } + result.success(tempPath) + } + "safReplaceFromPath" -> { + val uriStr = call.argument("uri") ?: "" + val srcPath = call.argument("src_path") ?: "" + val ok = withContext(Dispatchers.IO) { + writeUriFromPath(Uri.parse(uriStr), srcPath) + } + result.success(ok) + } + "safCreateFromPath" -> { + val treeUriStr = call.argument("tree_uri") ?: "" + val relativeDir = call.argument("relative_dir") ?: "" + val fileName = call.argument("file_name") ?: "" + val mimeType = call.argument("mime_type") ?: "application/octet-stream" + val srcPath = call.argument("src_path") ?: "" + val createdUri = withContext(Dispatchers.IO) { + if (treeUriStr.isBlank()) return@withContext null + val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null + val existing = dir.findFile(fileName) + val doc = existing ?: dir.createFile(mimeType, fileName) ?: return@withContext null + if (!writeUriFromPath(doc.uri, srcPath)) { + doc.delete() + return@withContext null + } + doc.uri.toString() + } + result.success(createdUri) + } + "openContentUri" -> { + val uriStr = call.argument("uri") ?: "" + val mimeType = call.argument("mime_type") ?: "" + try { + val uri = Uri.parse(uriStr) + val type = if (mimeType.isNotBlank()) mimeType else contentResolver.getType(uri) ?: "*/*" + val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, type) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + startActivity(intent) + result.success(null) + } catch (e: Exception) { + result.error("open_failed", e.message, null) + } + } "fetchLyrics" -> { val spotifyId = call.argument("spotify_id") ?: "" val trackName = call.argument("track_name") ?: "" @@ -669,7 +1302,9 @@ class MainActivity: FlutterActivity() { "downloadWithExtensions" -> { val requestJson = call.arguments as String val response = withContext(Dispatchers.IO) { - Gobackend.downloadWithExtensionsJSON(requestJson) + handleSafDownload(requestJson) { json -> + Gobackend.downloadWithExtensionsJSON(json) + } } result.success(response) } @@ -838,7 +1473,36 @@ class MainActivity: FlutterActivity() { val filePath = call.argument("file_path") ?: "" val metadataJson = call.argument("metadata") ?: "" val response = withContext(Dispatchers.IO) { - Gobackend.runPostProcessingJSON(filePath, metadataJson) + if (filePath.startsWith("content://")) { + runPostProcessingSaf(filePath, metadataJson) + } else { + Gobackend.runPostProcessingJSON(filePath, metadataJson) + } + } + result.success(response) + } + "runPostProcessingV2" -> { + val inputJson = call.argument("input") ?: "" + val metadataJson = call.argument("metadata") ?: "" + val response = withContext(Dispatchers.IO) { + val inputObj = if (inputJson.isNotBlank()) JSONObject(inputJson) else JSONObject() + val uriStr = inputObj.optString("uri", "") + val pathStr = inputObj.optString("path", "") + val effectiveUri = when { + uriStr.startsWith("content://") -> uriStr + pathStr.startsWith("content://") -> pathStr + else -> "" + } + + if (effectiveUri.isNotBlank()) { + runPostProcessingSafV2(effectiveUri, metadataJson) + } else { + if (pathStr.isNotBlank()) { + inputObj.put("name", File(pathStr).name) + inputObj.put("is_saf", false) + } + Gobackend.runPostProcessingV2JSON(inputObj.toString(), metadataJson) + } } result.success(response) } @@ -917,18 +1581,31 @@ class MainActivity: FlutterActivity() { "scanLibraryFolder" -> { val folderPath = call.argument("folder_path") ?: "" val response = withContext(Dispatchers.IO) { + safScanActive = false Gobackend.scanLibraryFolderJSON(folderPath) } result.success(response) } + "scanSafTree" -> { + val treeUri = call.argument("tree_uri") ?: "" + val response = withContext(Dispatchers.IO) { + scanSafTree(treeUri) + } + result.success(response) + } "getLibraryScanProgress" -> { val response = withContext(Dispatchers.IO) { - Gobackend.getLibraryScanProgressJSON() + if (safScanActive) { + safProgressToJson() + } else { + Gobackend.getLibraryScanProgressJSON() + } } result.success(response) } "cancelLibraryScan" -> { withContext(Dispatchers.IO) { + safScanCancel = true Gobackend.cancelLibraryScanJSON() } result.success(null) diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 3e6e40de..6afa0393 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -243,13 +243,17 @@ type AmazonDownloadResult struct { TrackNumber int DiscNumber int ISRC string + LyricsLRC string } func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + isSafOutput := strings.TrimSpace(req.OutputPath) != "" + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } } amazonURL := "" @@ -288,7 +292,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - if req.OutputDir != "." { + if !isSafOutput && req.OutputDir != "." { if err := os.MkdirAll(req.OutputDir, 0755); err != nil { return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err) } @@ -310,11 +314,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { "year": extractYear(req.ReleaseDate), "disc": req.DiscNumber, }) - filename = sanitizeFilename(filename) + ".flac" - outputPath := filepath.Join(req.OutputDir, filename) - - if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil + var outputPath string + if isSafOutput { + outputPath = strings.TrimSpace(req.OutputPath) + } else { + filename = sanitizeFilename(filename) + ".flac" + outputPath = filepath.Join(req.OutputDir, filename) + if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { + return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil + } } // START PARALLEL: Fetch cover and lyrics while downloading audio @@ -417,7 +425,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { lyricsMode = "embed" } - if lyricsMode == "external" || lyricsMode == "both" { + if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { GoLog("[Amazon] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr) @@ -459,7 +467,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } // Add to ISRC index for fast duplicate checking - AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) + if !isSafOutput { + AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) + } bitDepth := 0 sampleRate := 0 @@ -468,6 +478,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { sampleRate = quality.SampleRate } + lyricsLRC := "" + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsLRC = parallelResult.LyricsLRC + } + return AmazonDownloadResult{ FilePath: outputPath, BitDepth: bitDepth, @@ -479,5 +494,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { TrackNumber: actualTrackNum, DiscNumber: actualDiscNum, ISRC: req.ISRC, + LyricsLRC: lyricsLRC, }, nil } diff --git a/go_backend/exports.go b/go_backend/exports.go index 9b6e5585..b7ce52d6 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -128,6 +128,8 @@ type DownloadRequest struct { AlbumArtist string `json:"album_artist"` CoverURL string `json:"cover_url"` OutputDir string `json:"output_dir"` + OutputPath string `json:"output_path,omitempty"` + OutputExt string `json:"output_ext,omitempty"` FilenameFormat string `json:"filename_format"` Quality string `json:"quality"` EmbedLyrics bool `json:"embed_lyrics"` @@ -199,8 +201,10 @@ func DownloadTrack(requestJSON string) (string, error) { req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) + req.OutputPath = strings.TrimSpace(req.OutputPath) + req.OutputExt = strings.TrimSpace(req.OutputExt) - if req.OutputDir != "" { + if req.OutputPath == "" && req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } @@ -240,6 +244,7 @@ func DownloadTrack(requestJSON string) (string, error) { TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, + LyricsLRC: qobuzResult.LyricsLRC, } } err = qobuzErr @@ -257,6 +262,7 @@ func DownloadTrack(requestJSON string) (string, error) { TrackNumber: amazonResult.TrackNumber, DiscNumber: amazonResult.DiscNumber, ISRC: amazonResult.ISRC, + LyricsLRC: amazonResult.LyricsLRC, } } err = amazonErr @@ -336,8 +342,10 @@ func DownloadWithFallback(requestJSON string) (string, error) { req.AlbumName = strings.TrimSpace(req.AlbumName) req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) req.OutputDir = strings.TrimSpace(req.OutputDir) + req.OutputPath = strings.TrimSpace(req.OutputPath) + req.OutputExt = strings.TrimSpace(req.OutputExt) - if req.OutputDir != "" { + if req.OutputPath == "" && req.OutputDir != "" { AddAllowedDownloadDir(req.OutputDir) } @@ -402,6 +410,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { TrackNumber: qobuzResult.TrackNumber, DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, + LyricsLRC: qobuzResult.LyricsLRC, } } else if !errors.Is(qobuzErr, ErrDownloadCancelled) { GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) @@ -421,6 +430,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { TrackNumber: amazonResult.TrackNumber, DiscNumber: amazonResult.DiscNumber, ISRC: amazonResult.ISRC, + LyricsLRC: amazonResult.LyricsLRC, } } else if !errors.Is(amazonErr, ErrDownloadCancelled) { GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) @@ -569,6 +579,14 @@ func SetDownloadDirectory(path string) error { return setDownloadDir(path) } +// AllowDownloadDir adds a directory to the extension file sandbox allowlist. +func AllowDownloadDir(path string) { + if strings.TrimSpace(path) == "" { + return + } + AddAllowedDownloadDir(path) +} + func CheckDuplicate(outputDir, isrc string) (string, error) { existingFile, exists := CheckISRCExists(outputDir, isrc) @@ -1260,6 +1278,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) { return "", fmt.Errorf("invalid request: %w", err) } + req.TrackName = strings.TrimSpace(req.TrackName) + req.ArtistName = strings.TrimSpace(req.ArtistName) + req.AlbumName = strings.TrimSpace(req.AlbumName) + req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) + req.OutputDir = strings.TrimSpace(req.OutputDir) + req.OutputPath = strings.TrimSpace(req.OutputPath) + req.OutputExt = strings.TrimSpace(req.OutputExt) + if req.OutputPath == "" && req.OutputDir != "" { + AddAllowedDownloadDir(req.OutputDir) + } + result, err := DownloadWithExtensionFallback(req) if err != nil { return "", err @@ -1958,6 +1987,35 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } +func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) { + var metadata map[string]interface{} + if metadataJSON != "" { + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + metadata = make(map[string]interface{}) + } + } + + var input PostProcessInput + if inputJSON != "" { + if err := json.Unmarshal([]byte(inputJSON), &input); err != nil { + input = PostProcessInput{} + } + } + + manager := GetExtensionManager() + result, err := manager.RunPostProcessingV2(input, metadata) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func GetPostProcessingProvidersJSON() (string, error) { manager := GetExtensionManager() providers := manager.GetPostProcessingProviders() diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 0aedb2c9..93f3a825 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1123,6 +1123,10 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon } func buildOutputPath(req DownloadRequest) string { + if strings.TrimSpace(req.OutputPath) != "" { + return strings.TrimSpace(req.OutputPath) + } + metadata := map[string]interface{}{ "title": req.TrackName, "artist": req.ArtistName, @@ -1138,7 +1142,14 @@ func buildOutputPath(req DownloadRequest) string { filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)) } - return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename) + ext := strings.TrimSpace(req.OutputExt) + if ext == "" { + ext = ".flac" + } else if !strings.HasPrefix(ext, ".") { + ext = "." + ext + } + + return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext) } func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { @@ -1340,11 +1351,21 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} type PostProcessResult struct { Success bool `json:"success"` NewFilePath string `json:"new_file_path,omitempty"` + NewFileURI string `json:"new_file_uri,omitempty"` Error string `json:"error,omitempty"` BitDepth int `json:"bit_depth,omitempty"` SampleRate int `json:"sample_rate,omitempty"` } +type PostProcessInput struct { + Path string `json:"path,omitempty"` + URI string `json:"uri,omitempty"` + Name string `json:"name,omitempty"` + MimeType string `json:"mime_type,omitempty"` + Size int64 `json:"size,omitempty"` + IsSAF bool `json:"is_saf,omitempty"` +} + const PostProcessTimeout = 2 * time.Minute func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { @@ -1409,6 +1430,75 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str return &postResult, nil } +func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) { + if !p.extension.Manifest.HasPostProcessing() { + return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID) + } + + if !p.extension.Enabled { + return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) + } + + p.extension.VMMu.Lock() + defer p.extension.VMMu.Unlock() + + metadataJSON, _ := json.Marshal(metadata) + inputJSON, _ := json.Marshal(input) + filePath := input.Path + + script := fmt.Sprintf(` + (function() { + if (typeof extension !== 'undefined') { + if (typeof extension.postProcessV2 === 'function') { + return extension.postProcessV2(%s, %s, %q); + } + if (typeof extension.postProcess === 'function') { + return extension.postProcess(%q, %s, %q); + } + } + return null; + })() + `, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID) + + result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout) + if err != nil { + errMsg := err.Error() + if IsTimeoutError(err) { + errMsg = "postProcess timeout: extension took too long to complete" + } + return &PostProcessResult{ + Success: false, + Error: errMsg, + }, nil + } + + if result == nil || goja.IsUndefined(result) || goja.IsNull(result) { + return &PostProcessResult{ + Success: false, + Error: "postProcess returned null", + }, nil + } + + exported := result.Export() + jsonBytes, err := json.Marshal(exported) + if err != nil { + return &PostProcessResult{ + Success: false, + Error: fmt.Sprintf("failed to marshal result: %v", err), + }, nil + } + + var postResult PostProcessResult + if err := json.Unmarshal(jsonBytes, &postResult); err != nil { + return &PostProcessResult{ + Success: false, + Error: fmt.Sprintf("failed to parse result: %v", err), + }, nil + } + + return &postResult, nil +} + func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() @@ -1531,3 +1621,58 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil } + +// RunPostProcessingV2 runs all enabled post-processing hooks on a file input. +func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) { + providers := m.GetPostProcessingProviders() + if len(providers) == 0 { + return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil + } + + currentInput := input + for _, provider := range providers { + hooks := provider.extension.Manifest.GetPostProcessingHooks() + for _, hook := range hooks { + if !hook.DefaultEnabled { + continue + } + + ext := strings.ToLower(filepath.Ext(currentInput.Path)) + if ext == "" && currentInput.Name != "" { + ext = strings.ToLower(filepath.Ext(currentInput.Name)) + } + if len(hook.SupportedFormats) > 0 && ext != "" { + supported := false + for _, format := range hook.SupportedFormats { + if "."+format == ext || format == ext[1:] { + supported = true + break + } + } + if !supported { + continue + } + } + + GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path) + + result, err := provider.PostProcessV2(currentInput, metadata, hook.ID) + if err != nil { + GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err) + continue + } + + if result.Success && result.NewFilePath != "" { + currentInput.Path = result.NewFilePath + if currentInput.Name == "" { + currentInput.Name = filepath.Base(result.NewFilePath) + } + } + if result.Success && result.NewFileURI != "" { + currentInput.URI = result.NewFileURI + } + } + } + + return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil +} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index a8c38b8a..156ca492 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1016,13 +1016,17 @@ type QobuzDownloadResult struct { TrackNumber int DiscNumber int ISRC string + LyricsLRC string } func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { downloader := NewQobuzDownloader() - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + isSafOutput := strings.TrimSpace(req.OutputPath) != "" + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } } expectedDurationSec := req.DurationMS / 1000 @@ -1130,11 +1134,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { "year": extractYear(req.ReleaseDate), "disc": req.DiscNumber, }) - filename = sanitizeFilename(filename) + ".flac" - outputPath := filepath.Join(req.OutputDir, filename) - - if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil + var outputPath string + if isSafOutput { + outputPath = strings.TrimSpace(req.OutputPath) + } else { + filename = sanitizeFilename(filename) + ".flac" + outputPath = filepath.Join(req.OutputDir, filename) + if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { + return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil + } } qobuzQuality := "27" @@ -1227,7 +1235,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { lyricsMode = "embed" } - if lyricsMode == "external" || lyricsMode == "both" { + if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { GoLog("[Qobuz] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr) @@ -1248,7 +1256,14 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { fmt.Println("[Qobuz] No lyrics available from parallel fetch") } - AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) + if !isSafOutput { + AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) + } + + lyricsLRC := "" + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsLRC = parallelResult.LyricsLRC + } return QobuzDownloadResult{ FilePath: outputPath, @@ -1261,5 +1276,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { TrackNumber: actualTrackNumber, DiscNumber: req.DiscNumber, ISRC: track.ISRC, + LyricsLRC: lyricsLRC, }, nil } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 57632d26..36e3d3e0 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1024,13 +1024,16 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, } // For DASH format, determine correct M4A path - // If outputPath already ends with .m4a, use it directly - // Otherwise, convert .flac to .m4a + // If outputPath already ends with .m4a, use it directly. + // If outputPath ends with .flac, convert .flac to .m4a. + // Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is. var m4aPath string if strings.HasSuffix(outputPath, ".m4a") { m4aPath = outputPath - } else { + } else if strings.HasSuffix(outputPath, ".flac") { m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a" + } else { + m4aPath = outputPath } GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath) @@ -1406,8 +1409,11 @@ func isLatinScript(s string) bool { func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { downloader := NewTidalDownloader() - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + isSafOutput := strings.TrimSpace(req.OutputPath) != "" + if !isSafOutput { + if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { + return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil + } } expectedDurationSec := req.DurationMS / 1000 @@ -1606,31 +1612,49 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { "disc": req.DiscNumber, }) - var outputPath string - var m4aPath string - if quality == "HIGH" { - filename = sanitizeFilename(filename) + ".m4a" - outputPath = filepath.Join(req.OutputDir, filename) - m4aPath = outputPath - } else { - filename = sanitizeFilename(filename) + ".flac" - outputPath = filepath.Join(req.OutputDir, filename) - m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a" + outputExt := strings.TrimSpace(req.OutputExt) + if outputExt == "" { + if quality == "HIGH" { + outputExt = ".m4a" + } else { + outputExt = ".flac" + } + } else if !strings.HasPrefix(outputExt, ".") { + outputExt = "." + outputExt } - if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil - } - if quality != "HIGH" { - if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { - return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil + var outputPath string + var m4aPath string + if isSafOutput { + outputPath = strings.TrimSpace(req.OutputPath) + m4aPath = outputPath + } else { + if outputExt == ".m4a" || quality == "HIGH" { + filename = sanitizeFilename(filename) + ".m4a" + outputPath = filepath.Join(req.OutputDir, filename) + m4aPath = outputPath + } else { + filename = sanitizeFilename(filename) + ".flac" + outputPath = filepath.Join(req.OutputDir, filename) + m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a" + } + + if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { + return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil + } + if quality != "HIGH" { + if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 { + return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil + } } } - tmpPath := outputPath + ".m4a.tmp" - if _, err := os.Stat(tmpPath); err == nil { - GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) - os.Remove(tmpPath) + if !isSafOutput { + tmpPath := outputPath + ".m4a.tmp" + if _, err := os.Stat(tmpPath); err == nil { + GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath) + os.Remove(tmpPath) + } } GoLog("[Tidal] Using quality: %s\n", quality) @@ -1682,11 +1706,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } actualOutputPath := outputPath - if _, err := os.Stat(m4aPath); err == nil { - actualOutputPath = m4aPath - GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) - } else if _, err := os.Stat(outputPath); err != nil { - return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) + if !isSafOutput { + if _, err := os.Stat(m4aPath); err == nil { + actualOutputPath = m4aPath + GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath) + } else if _, err := os.Stat(outputPath); err != nil { + return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) + } } releaseDate := req.ReleaseDate @@ -1725,7 +1751,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } - if strings.HasSuffix(actualOutputPath, ".flac") { + actualExt := outputExt + if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") { + actualExt = ".m4a" + } + if actualExt == "" && !isSafOutput { + actualExt = strings.ToLower(filepath.Ext(actualOutputPath)) + } + + if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) { if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } @@ -1736,7 +1770,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { lyricsMode = "embed" } - if lyricsMode == "external" || lyricsMode == "both" { + if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { GoLog("[Tidal] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) @@ -1756,7 +1790,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } else if req.EmbedLyrics { fmt.Println("[Tidal] No lyrics available from parallel fetch") } - } else if strings.HasSuffix(actualOutputPath, ".m4a") { + } else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) { if quality == "HIGH" { GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n") @@ -1766,7 +1800,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { lyricsMode = "embed" } - if lyricsMode == "external" || lyricsMode == "both" { + if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") { GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode) if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr) @@ -1780,7 +1814,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) + if !isSafOutput { + AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) + } bitDepth := downloadInfo.BitDepth sampleRate := downloadInfo.SampleRate @@ -1788,15 +1824,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { if quality == "HIGH" { bitDepth = 0 sampleRate = 44100 - if parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" - } - if lyricsMode == "embed" || lyricsMode == "both" { - lyricsLRC = parallelResult.LyricsLRC - } - } + } + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + lyricsLRC = parallelResult.LyricsLRC } return TidalDownloadResult{ diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 418a18bf..ce43cf6f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -639,6 +639,14 @@ import Gobackend // Import Go framework let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error) if let error = error { throw error } return response + + case "runPostProcessingV2": + let args = call.arguments as! [String: Any] + let inputJson = args["input"] as? String ?? "" + let metadataJson = args["metadata"] as? String ?? "" + let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error) + if let error = error { throw error } + return response case "getPostProcessingProviders": let response = GobackendGetPostProcessingProvidersJSON(&error) diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 7de175c3..e8f8fcff 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -8,6 +8,8 @@ class AppSettings { final String audioQuality; final String filenameFormat; final String downloadDirectory; + final String storageMode; // 'app' or 'saf' + final String downloadTreeUri; // SAF persistable tree URI final bool autoFallback; final bool embedLyrics; final bool maxQualityCover; @@ -32,7 +34,7 @@ class AppSettings { final bool showExtensionStore; final String locale; final String lyricsMode; - final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128' + final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only @@ -47,6 +49,8 @@ class AppSettings { this.audioQuality = 'LOSSLESS', this.filenameFormat = '{title} - {artist}', this.downloadDirectory = '', + this.storageMode = 'app', + this.downloadTreeUri = '', this.autoFallback = true, this.embedLyrics = true, this.maxQualityCover = true, @@ -86,6 +90,8 @@ class AppSettings { String? audioQuality, String? filenameFormat, String? downloadDirectory, + String? storageMode, + String? downloadTreeUri, bool? autoFallback, bool? embedLyrics, bool? maxQualityCover, @@ -125,6 +131,8 @@ class AppSettings { audioQuality: audioQuality ?? this.audioQuality, filenameFormat: filenameFormat ?? this.filenameFormat, downloadDirectory: downloadDirectory ?? this.downloadDirectory, + storageMode: storageMode ?? this.storageMode, + downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, autoFallback: autoFallback ?? this.autoFallback, embedLyrics: embedLyrics ?? this.embedLyrics, maxQualityCover: maxQualityCover ?? this.maxQualityCover, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 287828f4..95f0b162 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -11,6 +11,8 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS', filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}', downloadDirectory: json['downloadDirectory'] as String? ?? '', + storageMode: json['storageMode'] as String? ?? 'app', + downloadTreeUri: json['downloadTreeUri'] as String? ?? '', autoFallback: json['autoFallback'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true, maxQualityCover: json['maxQualityCover'] as bool? ?? true, @@ -54,6 +56,8 @@ Map _$AppSettingsToJson(AppSettings instance) => 'audioQuality': instance.audioQuality, 'filenameFormat': instance.filenameFormat, 'downloadDirectory': instance.downloadDirectory, + 'storageMode': instance.storageMode, + 'downloadTreeUri': instance.downloadTreeUri, 'autoFallback': instance.autoFallback, 'embedLyrics': instance.embedLyrics, 'maxQualityCover': instance.maxQualityCover, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 5c8faa1e..73cd110f 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; final _log = AppLogger('DownloadQueue'); final _historyLog = AppLogger('DownloadHistory'); @@ -40,6 +41,11 @@ class DownloadHistoryItem { final String? albumArtist; final String? coverUrl; final String filePath; + final String? storageMode; + final String? downloadTreeUri; + final String? safRelativeDir; + final String? safFileName; + final bool safRepaired; final String service; final DateTime downloadedAt; final String? isrc; @@ -63,6 +69,11 @@ class DownloadHistoryItem { this.albumArtist, this.coverUrl, required this.filePath, + this.storageMode, + this.downloadTreeUri, + this.safRelativeDir, + this.safFileName, + this.safRepaired = false, required this.service, required this.downloadedAt, this.isrc, @@ -87,6 +98,11 @@ class DownloadHistoryItem { 'albumArtist': albumArtist, 'coverUrl': coverUrl, 'filePath': filePath, + 'storageMode': storageMode, + 'downloadTreeUri': downloadTreeUri, + 'safRelativeDir': safRelativeDir, + 'safFileName': safFileName, + 'safRepaired': safRepaired, 'service': service, 'downloadedAt': downloadedAt.toIso8601String(), 'isrc': isrc, @@ -112,6 +128,11 @@ class DownloadHistoryItem { albumArtist: _normalizeOptionalString(json['albumArtist'] as String?), coverUrl: json['coverUrl'] as String?, filePath: json['filePath'] as String, + storageMode: json['storageMode'] as String?, + downloadTreeUri: json['downloadTreeUri'] as String?, + safRelativeDir: json['safRelativeDir'] as String?, + safFileName: json['safFileName'] as String?, + safRepaired: json['safRepaired'] == true, service: json['service'] as String, downloadedAt: DateTime.parse(json['downloadedAt'] as String), isrc: json['isrc'] as String?, @@ -127,6 +148,44 @@ class DownloadHistoryItem { label: json['label'] as String?, copyright: json['copyright'] as String?, ); + + DownloadHistoryItem copyWith({ + String? filePath, + String? storageMode, + String? downloadTreeUri, + String? safRelativeDir, + String? safFileName, + bool? safRepaired, + }) { + return DownloadHistoryItem( + id: id, + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: albumArtist, + coverUrl: coverUrl, + filePath: filePath ?? this.filePath, + storageMode: storageMode ?? this.storageMode, + downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, + safRelativeDir: safRelativeDir ?? this.safRelativeDir, + safFileName: safFileName ?? this.safFileName, + safRepaired: safRepaired ?? this.safRepaired, + service: service, + downloadedAt: downloadedAt, + isrc: isrc, + spotifyId: spotifyId, + trackNumber: trackNumber, + discNumber: discNumber, + duration: duration, + releaseDate: releaseDate, + quality: quality, + bitDepth: bitDepth, + sampleRate: sampleRate, + genre: genre, + label: label, + copyright: copyright, + ); + } } class DownloadHistoryState { @@ -204,11 +263,84 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith(items: items); _historyLog.i('Loaded ${items.length} items from SQLite database'); + + if (Platform.isAndroid) { + Future.microtask(() async { + await _repairMissingSafEntries(items); + }); + } } catch (e, stack) { _historyLog.e('Failed to load history from database: $e', e, stack); } } + String _fileNameFromUri(String uri) { + try { + final parsed = Uri.parse(uri); + if (parsed.pathSegments.isNotEmpty) { + return Uri.decodeComponent(parsed.pathSegments.last); + } + } catch (_) {} + return ''; + } + + Future _repairMissingSafEntries(List items) async { + final updatedItems = [...items]; + var changed = false; + + for (var i = 0; i < items.length; i++) { + final item = items[i]; + if (item.storageMode != 'saf') continue; + if (item.safRepaired) continue; + if (item.downloadTreeUri == null || item.downloadTreeUri!.isEmpty) { + continue; + } + if (item.filePath.isEmpty || !isContentUri(item.filePath)) { + continue; + } + + final exists = await fileExists(item.filePath); + if (exists) continue; + + final fallbackName = item.safFileName ?? _fileNameFromUri(item.filePath); + if (fallbackName.isEmpty) { + _historyLog.w('Missing SAF filename for history item: ${item.id}'); + continue; + } + + try { + final resolved = await PlatformBridge.resolveSafFile( + treeUri: item.downloadTreeUri!, + relativeDir: item.safRelativeDir ?? '', + fileName: fallbackName, + ); + final newUri = resolved['uri'] as String? ?? ''; + if (newUri.isEmpty) continue; + + final newRelativeDir = resolved['relative_dir'] as String?; + final updated = item.copyWith( + filePath: newUri, + safRelativeDir: (newRelativeDir != null && newRelativeDir.isNotEmpty) + ? newRelativeDir + : item.safRelativeDir, + safFileName: fallbackName, + safRepaired: true, + ); + + updatedItems[i] = updated; + changed = true; + await _db.upsert(updated.toJson()); + _historyLog.i('Repaired SAF URI for history item: ${item.id}'); + } catch (e) { + _historyLog.w('Failed to repair SAF URI: $e'); + } + } + + if (changed) { + state = state.copyWith(items: updatedItems); + } + } + Future reloadFromStorage() async { await _loadFromDatabase(); } @@ -795,6 +927,111 @@ class DownloadQueueNotifier extends Notifier { .trim(); } + bool _isSafMode(AppSettings settings) { + return Platform.isAndroid && + settings.storageMode == 'saf' && + settings.downloadTreeUri.isNotEmpty; + } + + bool _isSafWriteFailure(Map result) { + final error = (result['error'] ?? result['message'] ?? '') + .toString() + .toLowerCase(); + if (error.isEmpty) return false; + return error.contains('saf') || + error.contains('content uri') || + error.contains('permission denied') || + error.contains('documentfile'); + } + + Future _buildRelativeOutputDir( + Track track, + String folderOrganization, { + bool separateSingles = false, + String albumFolderStructure = 'artist_album', + }) async { + final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName; + + if (separateSingles) { + final isSingle = track.isSingle; + final artistName = _sanitizeFolderName(albumArtist); + + if (albumFolderStructure == 'artist_album_singles') { + if (isSingle) { + return '$artistName/Singles'; + } + final albumName = _sanitizeFolderName(track.albumName); + return '$artistName/$albumName'; + } + + if (isSingle) { + return 'Singles'; + } + + final albumName = _sanitizeFolderName(track.albumName); + final year = _extractYear(track.releaseDate); + switch (albumFolderStructure) { + case 'album_only': + return 'Albums/$albumName'; + case 'artist_year_album': + final yearAlbum = year != null ? '[$year] $albumName' : albumName; + return 'Albums/$artistName/$yearAlbum'; + case 'year_album': + final yearAlbum = year != null ? '[$year] $albumName' : albumName; + return 'Albums/$yearAlbum'; + default: + return 'Albums/$artistName/$albumName'; + } + } + + if (folderOrganization == 'none') { + return ''; + } + + switch (folderOrganization) { + case 'artist': + return _sanitizeFolderName(albumArtist); + case 'album': + return _sanitizeFolderName(track.albumName); + case 'artist_album': + final artistName = _sanitizeFolderName(albumArtist); + final albumName = _sanitizeFolderName(track.albumName); + return '$artistName/$albumName'; + default: + return ''; + } + } + + String _determineOutputExt(String quality, String service) { + if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { + return '.m4a'; + } + return '.flac'; + } + + String _mimeTypeForExt(String ext) { + switch (ext.toLowerCase()) { + case '.m4a': + return 'audio/mp4'; + case '.mp3': + return 'audio/mpeg'; + case '.opus': + return 'audio/ogg'; + case '.flac': + default: + return 'audio/flac'; + } + } + + Future _getSafMimeType(String uri) async { + try { + final stat = await PlatformBridge.safStat(uri); + return stat['mime_type'] as String?; + } catch (_) { + return null; + } + } + String? _extractYear(String? releaseDate) { if (releaseDate == null || releaseDate.isEmpty) return null; final match = _yearRegex.firstMatch(releaseDate); @@ -1123,7 +1360,10 @@ void removeItem(String id) { 'cover_url': track.coverUrl ?? '', }; - final result = await PlatformBridge.runPostProcessing(filePath, metadata: metadata); + final result = await PlatformBridge.runPostProcessingV2( + filePath, + metadata: metadata, + ); if (result['success'] == true) { final hooksRun = result['hooks_run'] as int? ?? 0; @@ -1610,11 +1850,82 @@ void removeItem(String id) { } } + Future _copySafToTemp(String uri) async { + try { + return await PlatformBridge.copyContentUriToTemp(uri); + } catch (e) { + _log.w('Failed to copy SAF uri to temp: $e'); + return null; + } + } + + Future _writeTempToSaf({ + required String treeUri, + required String relativeDir, + required String fileName, + required String mimeType, + required String srcPath, + }) async { + try { + return await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: fileName, + mimeType: mimeType, + srcPath: srcPath, + ); + } catch (e) { + _log.w('Failed to write temp file to SAF: $e'); + return null; + } + } + + Future _writeLrcToSaf({ + required String treeUri, + required String relativeDir, + required String baseName, + required String lrcContent, + }) async { + try { + if (lrcContent.isEmpty) return; + final tempDir = await getTemporaryDirectory(); + final tempPath = '${tempDir.path}/$baseName.lrc'; + await File(tempPath).writeAsString(lrcContent); + final lrcName = '$baseName.lrc'; + final uri = await _writeTempToSaf( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: lrcName, + mimeType: 'text/plain', + srcPath: tempPath, + ); + if (uri != null) { + _log.d('External LRC saved to SAF: $lrcName'); + } else { + _log.w('Failed to write external LRC to SAF'); + } + try { + await File(tempPath).delete(); + } catch (_) {} + } catch (e) { + _log.w('Failed to create external LRC in SAF: $e'); + } + } + + Future _deleteSafFile(String uri) async { + try { + await PlatformBridge.safDelete(uri); + } catch (e) { + _log.w('Failed to delete SAF file: $e'); + } + } + Future _processQueue() async { if (state.isProcessing) return; // Check network connectivity before starting final settings = ref.read(settingsProvider); + final isSafMode = _isSafMode(settings); if (settings.downloadNetworkMode == 'wifi_only') { final connectivityResult = await Connectivity().checkConnectivity(); final hasWifi = connectivityResult.contains(ConnectivityResult.wifi); @@ -1651,13 +1962,13 @@ Future _processQueue() async { } } -if (state.outputDir.isEmpty) { + if (!isSafMode && state.outputDir.isEmpty) { _log.d('Output dir empty, initializing...'); await _initOutputDir(); } // iOS: Validate that outputDir is writable (not iCloud Drive which Go can't access) - if (Platform.isIOS && state.outputDir.isNotEmpty) { + if (!isSafMode && Platform.isIOS && state.outputDir.isNotEmpty) { final isICloudPath = state.outputDir.contains('Mobile Documents') || state.outputDir.contains('CloudDocs') || state.outputDir.contains('com~apple~CloudDocs'); @@ -1673,7 +1984,7 @@ if (state.outputDir.isEmpty) { } } - if (state.outputDir.isEmpty) { + if (!isSafMode && state.outputDir.isEmpty) { _log.d('Using fallback directory...'); final dir = await getApplicationDocumentsDirectory(); final musicDir = Directory('${dir.path}/SpotiFLAC'); @@ -1683,7 +1994,42 @@ if (state.outputDir.isEmpty) { state = state.copyWith(outputDir: musicDir.path); } - _log.d('Output directory: ${state.outputDir}'); + if (!isSafMode) { + _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, + relativeDir: '', + fileName: '.spotiflac_test', + 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( + item.id, + DownloadStatus.failed, + error: 'SAF permission invalid or revoked. Please reconfigure download location in Settings.', + ); + } + } + state = state.copyWith(isProcessing: false); + return; + } + } _log.d('Concurrent downloads: ${state.concurrentDownloads}'); if (state.concurrentDownloads > 1) { @@ -1931,14 +2277,48 @@ _log.i( final normalizedAlbumArtist = _normalizeOptionalString(trackToDownload.albumArtist); - final outputDir = await _buildOutputDir( - trackToDownload, - settings.folderOrganization, - separateSingles: settings.separateSingles, - albumFolderStructure: settings.albumFolderStructure, - ); - final quality = item.qualityOverride ?? state.audioQuality; + final isSafMode = _isSafMode(settings); + final relativeOutputDir = isSafMode + ? await _buildRelativeOutputDir( + trackToDownload, + settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, + ) + : ''; + String? appOutputDir; + final initialOutputDir = isSafMode + ? relativeOutputDir + : await _buildOutputDir( + trackToDownload, + settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, + ); + var effectiveOutputDir = initialOutputDir; + var effectiveSafMode = isSafMode; + + String? safFileName; + String? safBaseName; + String safOutputExt = _determineOutputExt(quality, item.service); + if (isSafMode) { + final baseName = await PlatformBridge.buildFilename( + state.filenameFormat, + { + 'title': trackToDownload.name, + 'artist': trackToDownload.artistName, + 'album': trackToDownload.albumName, + 'track': trackToDownload.trackNumber ?? 0, + 'disc': trackToDownload.discNumber ?? 0, + 'year': _extractYear(trackToDownload.releaseDate) ?? '', + }, + ); + final sanitized = await PlatformBridge.sanitizeFilename(baseName); + safBaseName = sanitized; + safFileName = '$sanitized$safOutputExt'; + } + String? finalSafFileName = safFileName; String? genre; String? label; @@ -1985,63 +2365,86 @@ _log.i( final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled); final useExtensions = settings.useExtensionProviders && hasActiveExtensions; - if (useExtensions) { - _log.d('Using extension providers for download'); - _log.d( - 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', - ); - _log.d('Output dir: $outputDir'); -result = await PlatformBridge.downloadWithExtensions( - isrc: trackToDownload.isrc ?? '', - spotifyId: trackToDownload.id, - trackName: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, - outputDir: outputDir, - filenameFormat: state.filenameFormat, - quality: quality, - trackNumber: trackToDownload.trackNumber ?? 1, - discNumber: trackToDownload.discNumber ?? 1, - releaseDate: trackToDownload.releaseDate, - itemId: item.id, - durationMs: trackToDownload.duration, - source: trackToDownload.source, - genre: genre, - label: label, - lyricsMode: settings.lyricsMode, - preferredService: item.service, - ); - } else if (state.autoFallback) { - _log.d('Using auto-fallback mode'); - _log.d( - 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', - ); - _log.d('Output dir: $outputDir'); - result = await PlatformBridge.downloadWithFallback( - isrc: trackToDownload.isrc ?? '', - spotifyId: trackToDownload.id, - trackName: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, - outputDir: outputDir, - filenameFormat: state.filenameFormat, - quality: quality, - trackNumber: trackToDownload.trackNumber ?? 1, - discNumber: trackToDownload.discNumber ?? 1, - releaseDate: trackToDownload.releaseDate, - preferredService: item.service, - itemId: item.id, - durationMs: trackToDownload.duration, - genre: genre, - label: label, - lyricsMode: settings.lyricsMode, - ); - } else { - result = await PlatformBridge.downloadTrack( + Future> runDownload({ + required bool useSaf, + required String outputDir, + }) async { + final storageMode = useSaf ? 'saf' : 'app'; + final treeUri = useSaf ? settings.downloadTreeUri : ''; + final relativeDir = useSaf ? outputDir : ''; + final fileName = useSaf ? (safFileName ?? '') : ''; + final outputExt = useSaf ? safOutputExt : ''; + + if (useExtensions) { + _log.d('Using extension providers for download'); + _log.d( + 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', + ); + _log.d('Output dir: $outputDir'); + return PlatformBridge.downloadWithExtensions( + isrc: trackToDownload.isrc ?? '', + spotifyId: trackToDownload.id, + trackName: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: trackToDownload.albumName, + albumArtist: normalizedAlbumArtist, + coverUrl: trackToDownload.coverUrl, + outputDir: outputDir, + filenameFormat: state.filenameFormat, + quality: quality, + trackNumber: trackToDownload.trackNumber ?? 1, + discNumber: trackToDownload.discNumber ?? 1, + releaseDate: trackToDownload.releaseDate, + itemId: item.id, + durationMs: trackToDownload.duration, + source: trackToDownload.source, + genre: genre, + label: label, + lyricsMode: settings.lyricsMode, + preferredService: item.service, + storageMode: storageMode, + safTreeUri: treeUri, + safRelativeDir: relativeDir, + safFileName: fileName, + safOutputExt: outputExt, + ); + } + + if (state.autoFallback) { + _log.d('Using auto-fallback mode'); + _log.d( + 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', + ); + _log.d('Output dir: $outputDir'); + return PlatformBridge.downloadWithFallback( + isrc: trackToDownload.isrc ?? '', + spotifyId: trackToDownload.id, + trackName: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: trackToDownload.albumName, + albumArtist: normalizedAlbumArtist, + coverUrl: trackToDownload.coverUrl, + outputDir: outputDir, + filenameFormat: state.filenameFormat, + quality: quality, + trackNumber: trackToDownload.trackNumber ?? 1, + discNumber: trackToDownload.discNumber ?? 1, + releaseDate: trackToDownload.releaseDate, + preferredService: item.service, + itemId: item.id, + durationMs: trackToDownload.duration, + genre: genre, + label: label, + lyricsMode: settings.lyricsMode, + storageMode: storageMode, + safTreeUri: treeUri, + safRelativeDir: relativeDir, + safFileName: fileName, + safOutputExt: outputExt, + ); + } + + return PlatformBridge.downloadTrack( isrc: trackToDownload.isrc ?? '', service: item.service, spotifyId: trackToDownload.id, @@ -2058,9 +2461,41 @@ result = await PlatformBridge.downloadWithExtensions( releaseDate: trackToDownload.releaseDate, itemId: item.id, durationMs: trackToDownload.duration, + storageMode: storageMode, + safTreeUri: treeUri, + safRelativeDir: relativeDir, + safFileName: fileName, + safOutputExt: outputExt, ); } + result = await runDownload( + useSaf: effectiveSafMode, + outputDir: effectiveOutputDir, + ); + + if (effectiveSafMode && + result['success'] != true && + _isSafWriteFailure(result)) { + _log.w('SAF write failed, retrying with app-private storage'); + appOutputDir ??= await _buildOutputDir( + trackToDownload, + settings.folderOrganization, + separateSingles: settings.separateSingles, + albumFolderStructure: settings.albumFolderStructure, + ); + final fallbackResult = await runDownload( + useSaf: false, + outputDir: appOutputDir, + ); + if (fallbackResult['success'] == true) { + effectiveSafMode = false; + effectiveOutputDir = appOutputDir; + finalSafFileName = null; + result = fallbackResult; + } + } + _log.d('Result: $result'); final currentItem = state.items.firstWhere( @@ -2071,21 +2506,18 @@ result = await PlatformBridge.downloadWithExtensions( _log.i('Download was cancelled, skipping result processing'); final filePath = result['file_path'] as String?; if (filePath != null && result['success'] == true) { - try { - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - _log.d('Deleted cancelled download file: $filePath'); - } - } catch (e) { - _log.w('Failed to delete cancelled file: $e'); - } + await deleteFile(filePath); + _log.d('Deleted cancelled download file: $filePath'); } return; } if (result['success'] == true) { var filePath = result['file_path'] as String?; + final reportedFileName = result['file_name'] as String?; + if (effectiveSafMode && reportedFileName != null && reportedFileName.isNotEmpty) { + finalSafFileName = reportedFileName; + } // Check if file already existed (detected via ISRC match in Go backend) final wasExisting = result['already_exists'] == true; @@ -2110,177 +2542,378 @@ result = await PlatformBridge.downloadWithExtensions( _log.i('Actual quality: $actualQuality'); } - if (filePath != null && filePath.endsWith('.m4a')) { - // For HIGH quality (Tidal AAC 320kbps), convert to MP3 or Opus - if (quality == 'HIGH') { - final tidalHighFormat = settings.tidalHighFormat; - _log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...'); - - try { - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.95, - ); - - final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; - final convertedPath = await FFmpegService.convertM4aToLossy( - filePath, - format: format, - bitrate: tidalHighFormat, - deleteOriginal: true, - ); - - if (convertedPath != null) { - filePath = convertedPath; - final bitrateDisplay = tidalHighFormat.contains('_') - ? '${tidalHighFormat.split('_').last}kbps' - : '320kbps'; - actualQuality = '${format.toUpperCase()} $bitrateDisplay'; - _log.i('Successfully converted M4A to $format: $convertedPath'); - - _log.i('Embedding metadata to $format...'); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.99, - ); - - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - if (format == 'mp3') { - await _embedMetadataToMp3( - convertedPath, - trackToDownload, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, + final isContentUriPath = filePath != null && isContentUri(filePath); + final mimeType = isContentUriPath ? await _getSafMimeType(filePath) : null; + final isM4aFile = filePath != null && + (filePath.endsWith('.m4a') || + (mimeType != null && mimeType.contains('mp4'))); + + if (isM4aFile) { + // At this point filePath is guaranteed non-null by isM4aFile check + final currentFilePath = filePath; + + if (isContentUriPath && effectiveSafMode) { + if (quality == 'HIGH') { + final tidalHighFormat = settings.tidalHighFormat; + _log.i('Tidal HIGH quality (SAF), converting M4A to $tidalHighFormat...'); + + final tempPath = await _copySafToTemp(currentFilePath); + if (tempPath != null) { + String? convertedPath; + try { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, ); - } else { - await _embedMetadataToOpus( - convertedPath, - trackToDownload, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, + + final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + convertedPath = await FFmpegService.convertM4aToLossy( + tempPath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: false, ); + + if (convertedPath != null) { + _log.i('Successfully converted M4A to $format (temp): $convertedPath'); + _log.i('Embedding metadata to $format...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, + ); + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + if (format == 'mp3') { + await _embedMetadataToMp3( + convertedPath, + trackToDownload, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } else { + await _embedMetadataToOpus( + convertedPath, + trackToDownload, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } + + final newExt = format == 'opus' ? '.opus' : '.mp3'; + final newFileName = '${safBaseName ?? 'track'}$newExt'; + final newUri = await _writeTempToSaf( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: newFileName, + mimeType: _mimeTypeForExt(newExt), + srcPath: convertedPath, + ); + + if (newUri != null) { + await _deleteSafFile(currentFilePath); + filePath = newUri; + finalSafFileName = newFileName; + final bitrateDisplay = tidalHighFormat.contains('_') + ? '${tidalHighFormat.split('_').last}kbps' + : '320kbps'; + actualQuality = '${format.toUpperCase()} $bitrateDisplay'; + } else { + _log.w('Failed to write converted $format to SAF, keeping M4A'); + actualQuality = 'AAC 320kbps'; + } + } else { + _log.w('M4A to $format conversion failed, keeping M4A file'); + actualQuality = 'AAC 320kbps'; + } + } catch (e) { + _log.w('SAF M4A conversion failed: $e'); + actualQuality = 'AAC 320kbps'; + } finally { + // Clean up temp files + try { await File(tempPath).delete(); } catch (_) {} + if (convertedPath != null) { + try { await File(convertedPath).delete(); } catch (_) {} + } + } + } + } else { + _log.d('M4A file detected (SAF), converting to FLAC...'); + final tempPath = await _copySafToTemp(currentFilePath); + if (tempPath != null) { + String? flacPath; + try { + final length = await File(tempPath).length(); + if (length < 1024) { + _log.w('Temp M4A is too small (<1KB), skipping conversion'); + } else { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); + flacPath = await FFmpegService.convertM4aToFlac(tempPath); + if (flacPath != null) { + _log.d('Converted to FLAC (temp): $flacPath'); + _log.d('Embedding metadata and cover to converted FLAC...'); + + Track finalTrack = trackToDownload; + if (result.containsKey('track_number') || + result.containsKey('release_date')) { + final backendTrackNum = result['track_number'] as int?; + final backendDiscNum = result['disc_number'] as int?; + final backendYear = result['release_date'] as String?; + final backendAlbum = result['album'] as String?; + + final newTrackNumber = + (backendTrackNum != null && backendTrackNum > 0) + ? backendTrackNum + : trackToDownload.trackNumber; + final newDiscNumber = + (backendDiscNum != null && backendDiscNum > 0) + ? backendDiscNum + : trackToDownload.discNumber; + + finalTrack = Track( + id: trackToDownload.id, + name: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: backendAlbum ?? trackToDownload.albumName, + albumArtist: normalizedAlbumArtist, + coverUrl: trackToDownload.coverUrl, + duration: trackToDownload.duration, + isrc: trackToDownload.isrc, + trackNumber: newTrackNumber, + discNumber: newDiscNumber, + releaseDate: backendYear ?? trackToDownload.releaseDate, + deezerId: trackToDownload.deezerId, + availability: trackToDownload.availability, + albumType: trackToDownload.albumType, + source: trackToDownload.source, + ); + } + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + await _embedMetadataAndCover( + flacPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + + final newFileName = '${safBaseName ?? 'track'}.flac'; + final newUri = await _writeTempToSaf( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + fileName: newFileName, + mimeType: _mimeTypeForExt('.flac'), + srcPath: flacPath, + ); + + if (newUri != null) { + await _deleteSafFile(currentFilePath); + filePath = newUri; + finalSafFileName = newFileName; + } else { + _log.w('Failed to write FLAC to SAF, keeping M4A'); + } + } else { + _log.w('FFmpeg conversion returned null, keeping M4A file'); + } + } + } catch (e) { + _log.w('SAF M4A->FLAC conversion failed: $e'); + } finally { + // Clean up temp files + try { await File(tempPath).delete(); } catch (_) {} + if (flacPath != null) { + try { await File(flacPath).delete(); } catch (_) {} + } } - _log.d('Metadata embedded successfully'); - } else { - _log.w('M4A to $format conversion failed, keeping M4A file'); - actualQuality = 'AAC 320kbps'; } - } catch (e) { - _log.w('M4A conversion process failed: $e, keeping M4A file'); - actualQuality = 'AAC 320kbps'; } } else { - _log.d( - 'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', - ); + // Local file path flow (original) + if (quality == 'HIGH') { + final tidalHighFormat = settings.tidalHighFormat; + _log.i('Tidal HIGH quality download, converting M4A to $tidalHighFormat...'); - try { - final file = File(filePath); - if (!await file.exists()) { - _log.e('File does not exist at path: $filePath'); - } else { - final length = await file.length(); - _log.i('File size before conversion: ${length / 1024} KB'); - - if (length < 1024) { - _log.w( - 'File is too small (<1KB), skipping conversion. Download might be corrupt.', - ); - } else { + try { updateItemStatus( item.id, DownloadStatus.downloading, progress: 0.95, ); - final flacPath = await FFmpegService.convertM4aToFlac(filePath); - if (flacPath != null) { - filePath = flacPath; - _log.d('Converted to FLAC: $flacPath'); + final format = tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + final convertedPath = await FFmpegService.convertM4aToLossy( + currentFilePath, + format: format, + bitrate: tidalHighFormat, + deleteOriginal: true, + ); - _log.d('Embedding metadata and cover to converted FLAC...'); - try { - Track finalTrack = trackToDownload; - if (result.containsKey('track_number') || - result.containsKey('release_date')) { - _log.d( - 'Using metadata from backend response for embedding', - ); - final backendTrackNum = result['track_number'] as int?; - final backendDiscNum = result['disc_number'] as int?; - final backendYear = result['release_date'] as String?; - final backendAlbum = result['album'] as String?; + if (convertedPath != null) { + filePath = convertedPath; + final bitrateDisplay = tidalHighFormat.contains('_') + ? '${tidalHighFormat.split('_').last}kbps' + : '320kbps'; + actualQuality = '${format.toUpperCase()} $bitrateDisplay'; + _log.i('Successfully converted M4A to $format: $convertedPath'); - _log.d( - 'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear', - ); + _log.i('Embedding metadata to $format...'); + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.99, + ); - final newTrackNumber = - (backendTrackNum != null && backendTrackNum > 0) - ? backendTrackNum - : trackToDownload.trackNumber; - final newDiscNumber = - (backendDiscNum != null && backendDiscNum > 0) - ? backendDiscNum - : trackToDownload.discNumber; + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; - _log.d( - 'Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber', - ); - - finalTrack = Track( - id: trackToDownload.id, - name: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: backendAlbum ?? trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, - duration: trackToDownload.duration, - isrc: trackToDownload.isrc, - trackNumber: newTrackNumber, - discNumber: newDiscNumber, - releaseDate: backendYear ?? trackToDownload.releaseDate, - deezerId: trackToDownload.deezerId, - availability: trackToDownload.availability, - albumType: trackToDownload.albumType, - source: trackToDownload.source, - ); - } - - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - if (backendGenre != null || backendLabel != null || backendCopyright != null) { - _log.d('Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright'); - } - - await _embedMetadataAndCover( - flacPath, - finalTrack, + if (format == 'mp3') { + await _embedMetadataToMp3( + convertedPath, + trackToDownload, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } else { + await _embedMetadataToOpus( + convertedPath, + trackToDownload, genre: backendGenre ?? genre, label: backendLabel ?? label, copyright: backendCopyright, ); - _log.d('Metadata and cover embedded successfully'); - } catch (e) { - _log.w('Warning: Failed to embed metadata/cover: $e'); } + _log.d('Metadata embedded successfully'); } else { - _log.w('FFmpeg conversion returned null, keeping M4A file'); + _log.w('M4A to $format conversion failed, keeping M4A file'); + actualQuality = 'AAC 320kbps'; } + } catch (e) { + _log.w('M4A conversion process failed: $e, keeping M4A file'); + actualQuality = 'AAC 320kbps'; + } + } else { + _log.d( + 'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...', + ); + + try { + final file = File(currentFilePath); + if (!await file.exists()) { + _log.e('File does not exist at path: $filePath'); + } else { + final length = await file.length(); + _log.i('File size before conversion: ${length / 1024} KB'); + + if (length < 1024) { + _log.w( + 'File is too small (<1KB), skipping conversion. Download might be corrupt.', + ); + } else { + updateItemStatus( + item.id, + DownloadStatus.downloading, + progress: 0.95, + ); + final flacPath = await FFmpegService.convertM4aToFlac(currentFilePath); + + if (flacPath != null) { + filePath = flacPath; + _log.d('Converted to FLAC: $flacPath'); + + _log.d('Embedding metadata and cover to converted FLAC...'); + try { + Track finalTrack = trackToDownload; + if (result.containsKey('track_number') || + result.containsKey('release_date')) { + _log.d( + 'Using metadata from backend response for embedding', + ); + final backendTrackNum = result['track_number'] as int?; + final backendDiscNum = result['disc_number'] as int?; + final backendYear = result['release_date'] as String?; + final backendAlbum = result['album'] as String?; + + _log.d( + 'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear', + ); + + final newTrackNumber = + (backendTrackNum != null && backendTrackNum > 0) + ? backendTrackNum + : trackToDownload.trackNumber; + final newDiscNumber = + (backendDiscNum != null && backendDiscNum > 0) + ? backendDiscNum + : trackToDownload.discNumber; + + _log.d( + 'Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber', + ); + + finalTrack = Track( + id: trackToDownload.id, + name: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: backendAlbum ?? trackToDownload.albumName, + albumArtist: normalizedAlbumArtist, + coverUrl: trackToDownload.coverUrl, + duration: trackToDownload.duration, + isrc: trackToDownload.isrc, + trackNumber: newTrackNumber, + discNumber: newDiscNumber, + releaseDate: backendYear ?? trackToDownload.releaseDate, + deezerId: trackToDownload.deezerId, + availability: trackToDownload.availability, + albumType: trackToDownload.albumType, + source: trackToDownload.source, + ); + } + + final backendGenre = result['genre'] as String?; + final backendLabel = result['label'] as String?; + final backendCopyright = result['copyright'] as String?; + + if (backendGenre != null || backendLabel != null || backendCopyright != null) { + _log.d('Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright'); + } + + await _embedMetadataAndCover( + flacPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + _log.d('Metadata and cover embedded successfully'); + } catch (e) { + _log.w('Warning: Failed to embed metadata/cover: $e'); + } + } else { + _log.w('FFmpeg conversion returned null, keeping M4A file'); + } + } + } + } catch (e) { + _log.w('FFmpeg conversion process failed: $e, keeping M4A file'); } } - } catch (e) { - _log.w('FFmpeg conversion process failed: $e, keeping M4A file'); - } } } @@ -2291,15 +2924,8 @@ result = await PlatformBridge.downloadWithExtensions( if (itemAfterDownload.status == DownloadStatus.skipped) { _log.i('Download was cancelled during finalization, cleaning up'); if (filePath != null) { - try { - final file = File(filePath); - if (await file.exists()) { - await file.delete(); - _log.d('Deleted cancelled download file: $filePath'); - } - } catch (e) { - _log.w('Failed to delete cancelled file: $e'); - } + await deleteFile(filePath); + _log.d('Deleted cancelled download file: $filePath'); } return; } @@ -2311,6 +2937,43 @@ result = await PlatformBridge.downloadWithExtensions( filePath: filePath, ); + final lyricsMode = settings.lyricsMode; + final shouldSaveExternalLrc = + settings.embedLyrics && (lyricsMode == 'external' || lyricsMode == 'both'); + if (shouldSaveExternalLrc && + effectiveSafMode && + filePath != null && + isContentUri(filePath)) { + String? lrcContent = result['lyrics_lrc'] as String?; + if (lrcContent == null || lrcContent.isEmpty) { + try { + lrcContent = await PlatformBridge.getLyricsLRC( + trackToDownload.id, + trackToDownload.name, + trackToDownload.artistName, + durationMs: trackToDownload.duration * 1000, + ); + } catch (e) { + _log.w('Failed to fetch lyrics for external LRC: $e'); + } + } + + if (lrcContent != null && lrcContent.isNotEmpty) { + final baseName = finalSafFileName != null + ? finalSafFileName.replaceFirst(RegExp(r'\.[^.]+$'), '') + : safBaseName ?? + await PlatformBridge.sanitizeFilename( + '${trackToDownload.artistName} - ${trackToDownload.name}', + ); + await _writeLrcToSaf( + treeUri: settings.downloadTreeUri, + relativeDir: effectiveOutputDir, + baseName: baseName, + lrcContent: lrcContent, + ); + } + } + if (filePath != null) { await _runPostProcessingHooks(filePath, trackToDownload); } @@ -2385,6 +3048,11 @@ result = await PlatformBridge.downloadWithExtensions( albumArtist: historyAlbumArtist, coverUrl: trackToDownload.coverUrl, filePath: filePath, + storageMode: effectiveSafMode ? 'saf' : 'app', + downloadTreeUri: effectiveSafMode ? settings.downloadTreeUri : null, + safRelativeDir: effectiveSafMode ? effectiveOutputDir : null, + safFileName: effectiveSafMode ? (finalSafFileName ?? safFileName) : null, + safRepaired: false, service: result['service'] as String? ?? item.service, downloadedAt: DateTime.now(), isrc: (backendISRC != null && backendISRC.isNotEmpty) diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 8bee7fcd..680d65d0 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -17,6 +17,7 @@ class LocalLibraryState { final String? scanCurrentFile; final int scanTotalFiles; final int scanErrorCount; + final bool scanWasCancelled; final DateTime? lastScannedAt; final Set _isrcSet; final Set _trackKeySet; @@ -29,6 +30,7 @@ class LocalLibraryState { this.scanCurrentFile, this.scanTotalFiles = 0, this.scanErrorCount = 0, + this.scanWasCancelled = false, this.lastScannedAt, }) : _isrcSet = items .where((item) => item.isrc != null && item.isrc!.isNotEmpty) @@ -72,6 +74,7 @@ class LocalLibraryState { String? scanCurrentFile, int? scanTotalFiles, int? scanErrorCount, + bool? scanWasCancelled, DateTime? lastScannedAt, }) { return LocalLibraryState( @@ -81,6 +84,7 @@ class LocalLibraryState { scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile, scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles, scanErrorCount: scanErrorCount ?? this.scanErrorCount, + scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, lastScannedAt: lastScannedAt ?? this.lastScannedAt, ); } @@ -90,6 +94,7 @@ class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; Timer? _progressTimer; bool _isLoaded = false; + bool _scanCancelRequested = false; @override LocalLibraryState build() { @@ -142,6 +147,7 @@ class LocalLibraryNotifier extends Notifier { return; } + _scanCancelRequested = false; _log.i('Starting library scan: $folderPath'); state = state.copyWith( isScanning: true, @@ -149,6 +155,7 @@ class LocalLibraryNotifier extends Notifier { scanCurrentFile: null, scanTotalFiles: 0, scanErrorCount: 0, + scanWasCancelled: false, ); try { @@ -163,7 +170,14 @@ class LocalLibraryNotifier extends Notifier { _startProgressPolling(); try { - final results = await PlatformBridge.scanLibraryFolder(folderPath); + final isSaf = folderPath.startsWith('content://'); + final results = isSaf + ? await PlatformBridge.scanSafTree(folderPath) + : await PlatformBridge.scanLibraryFolder(folderPath); + if (_scanCancelRequested) { + state = state.copyWith(isScanning: false, scanWasCancelled: true); + return; + } final items = []; for (final json in results) { @@ -187,12 +201,13 @@ class LocalLibraryNotifier extends Notifier { isScanning: false, scanProgress: 100, lastScannedAt: now, + scanWasCancelled: false, ); _log.i('Scan complete: ${items.length} tracks found'); } catch (e, stack) { _log.e('Library scan failed: $e', e, stack); - state = state.copyWith(isScanning: false); + state = state.copyWith(isScanning: false, scanWasCancelled: false); } finally { _stopProgressPolling(); } @@ -227,8 +242,9 @@ class LocalLibraryNotifier extends Notifier { if (!state.isScanning) return; _log.i('Cancelling library scan'); + _scanCancelRequested = true; await PlatformBridge.cancelLibraryScan(); - state = state.copyWith(isScanning: false); + state = state.copyWith(isScanning: false, scanWasCancelled: true); _stopProgressPolling(); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 7577ec20..dc9a257f 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -48,6 +48,9 @@ class SettingsNotifier extends Notifier { } if (lastMigration < _currentMigrationVersion) { + if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') { + state = state.copyWith(storageMode: 'saf'); + } await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); } } @@ -120,6 +123,22 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setStorageMode(String mode) { + final normalized = mode == 'saf' ? 'saf' : 'app'; + state = state.copyWith(storageMode: normalized); + _saveSettings(); + } + + void setDownloadTreeUri(String uri, {String? displayName}) { + final nextDisplay = displayName ?? state.downloadDirectory; + state = state.copyWith( + downloadTreeUri: uri, + storageMode: uri.isNotEmpty ? 'saf' : state.storageMode, + downloadDirectory: nextDisplay, + ); + _saveSettings(); + } + void setAutoFallback(bool enabled) { state = state.copyWith(autoFallback: enabled); _saveSettings(); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 8dd18e77..e6703453 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -12,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen; @@ -695,8 +695,8 @@ child: ListTile( if (isInHistory) { final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); if (historyItem != null) { - final fileExists = await File(historyItem.filePath).exists(); - if (fileExists) { + final exists = await fileExists(historyItem.filePath); + if (exists) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)))); } diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 9f5b8123..148957df 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -14,6 +13,7 @@ import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -1061,8 +1061,8 @@ if (hasValidImage) if (isInHistory) { final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); if (historyItem != null) { - final fileExists = await File(historyItem.filePath).exists(); - if (fileExists) { + final exists = await fileExists(historyItem.filePath); + if (exists) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 0dca2c86..07b4099b 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1,13 +1,11 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/utils/mime_utils.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -180,10 +178,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final item = currentTracks.where((e) => e.id == id).firstOrNull; if (item != null) { try { - final file = File(item.filePath); - if (await file.exists()) { - await file.delete(); - } + await deleteFile(item.filePath); } catch (_) {} historyNotifier.removeFromHistory(id); deletedCount++; @@ -202,8 +197,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { Future _openFile(String filePath) async { try { - final mimeType = audioMimeTypeForPath(filePath); - await OpenFilex.open(filePath, type: mimeType); + await openFile(filePath); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 88842b41..8e9bb468 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -19,6 +19,7 @@ import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/models/download_item.dart'; @@ -2569,8 +2570,8 @@ class _TrackItemWithStatus extends ConsumerWidget { if (isInHistory) { final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); if (historyItem != null) { - final fileExists = await File(historyItem.filePath).exists(); - if (fileExists) { + final exists = await fileExists(historyItem.filePath); + if (exists) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 7c7f345d..c6b18aba 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -2,9 +2,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/utils/mime_utils.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/palette_service.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; @@ -192,10 +191,7 @@ class _LocalAlbumScreenState extends ConsumerState { final item = currentTracks.where((e) => e.id == id).firstOrNull; if (item != null) { try { - final file = File(item.filePath); - if (await file.exists()) { - await file.delete(); - } + await deleteFile(item.filePath); } catch (_) {} libraryNotifier.removeItem(id); deletedCount++; @@ -219,8 +215,7 @@ class _LocalAlbumScreenState extends ConsumerState { Future _openFile(String filePath) async { try { - final mimeType = audioMimeTypeForPath(filePath); - await OpenFilex.open(filePath, type: mimeType); + await openFile(filePath); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 8cc4c74a..4ce241f5 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -9,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; @@ -512,8 +512,8 @@ leading: track.coverUrl != null if (isInHistory) { final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); if (historyItem != null) { - final fileExists = await File(historyItem.filePath).exists(); - if (fileExists) { + final exists = await fileExists(historyItem.filePath); + if (exists) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)))); } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index f930f846..5b689a38 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5,10 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; -import 'package:spotiflac_android/utils/mime_utils.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -279,6 +278,7 @@ class _QueueTabState extends ConsumerState { String _localFilterQueryCache = ''; List _filteredLocalItemsCache = const []; final Map _unifiedItemsCache = {}; + bool _showSafRepairedBadge = false; // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' @@ -637,10 +637,7 @@ class _QueueTabState extends ConsumerState { if (item != null) { try { final cleanPath = _cleanFilePath(item.filePath); - final file = File(cleanPath); - if (await file.exists()) { - await file.delete(); - } + await deleteFile(cleanPath); } catch (_) {} // Remove from appropriate database @@ -695,7 +692,7 @@ class _QueueTabState extends ConsumerState { } _pendingChecks.add(cleanPath); Future.microtask(() async { - final exists = await File(cleanPath).exists(); + final exists = await fileExists(cleanPath); _pendingChecks.remove(cleanPath); final previous = _fileExistsCache[cleanPath]; _fileExistsCache[cleanPath] = exists; @@ -1020,8 +1017,7 @@ class _QueueTabState extends ConsumerState { Future _openFile(String filePath) async { final cleanPath = _cleanFilePath(filePath); try { - final mimeType = audioMimeTypeForPath(cleanPath); - await OpenFilex.open(cleanPath, type: mimeType); + await openFile(cleanPath); } catch (e) { if (mounted) { ScaffoldMessenger.of( @@ -1315,6 +1311,7 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items)); final groupedLocalAlbums = historyStats.groupedLocalAlbums; final albumCount = historyStats.totalAlbumCount; final singleCount = historyStats.totalSingleTracks; + final hasSafRepairedItems = allHistoryItems.any((item) => item.safRepaired); final bottomPadding = MediaQuery.of(context).padding.bottom; @@ -1584,6 +1581,7 @@ const Spacer(), albumCounts: historyStats.albumCounts, localAlbumCounts: historyStats.localAlbumCounts, localLibraryItems: localLibraryItems, + hasSafRepairedItems: hasSafRepairedItems, ); }, ), @@ -1705,6 +1703,7 @@ child: _buildSelectionBottomBar( required Map albumCounts, required Map localAlbumCounts, required List localLibraryItems, + required bool hasSafRepairedItems, }) { final historyItems = _resolveHistoryItems( filterMode: filterMode, @@ -1780,6 +1779,27 @@ child: _buildSelectionBottomBar( ), ), ), + if (!_isSelectionMode && hasSafRepairedItems) + IconButton( + onPressed: () { + setState(() { + _showSafRepairedBadge = !_showSafRepairedBadge; + }); + }, + icon: Icon( + _showSafRepairedBadge ? Icons.build : Icons.build_outlined, + size: 18, + ), + tooltip: _showSafRepairedBadge + ? 'Hide SAF repaired badge' + : 'Show SAF repaired badge', + style: IconButton.styleFrom( + backgroundColor: _showSafRepairedBadge + ? colorScheme.tertiaryContainer.withValues(alpha: 0.6) + : colorScheme.surfaceContainerHighest, + visualDensity: VisualDensity.compact, + ), + ), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( onPressed: () => _enterSelectionMode(filteredUnifiedItems.first.id), @@ -2911,6 +2931,10 @@ child: CachedNetworkImage( final sourceTextColor = isDownloaded ? colorScheme.onPrimaryContainer : colorScheme.onSecondaryContainer; + final showSafRepaired = _showSafRepairedBadge && + isDownloaded && + item.historyItem != null && + item.historyItem!.safRepaired; return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), @@ -3007,6 +3031,38 @@ child: CachedNetworkImage( ), ), ), + if (showSafRepaired) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.build, + size: 10, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 4), + Text( + 'SAF repaired', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colorScheme.onTertiaryContainer, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], const SizedBox(width: 8), Text( dateStr, @@ -3092,6 +3148,10 @@ child: CachedNetworkImage( final fileExists = _checkFileExists(item.filePath); final isSelected = _selectedIds.contains(item.id); final isDownloaded = item.source == LibraryItemSource.downloaded; + final showSafRepaired = _showSafRepairedBadge && + isDownloaded && + item.historyItem != null && + item.historyItem!.safRepaired; return GestureDetector( onTap: _isSelectionMode @@ -3168,6 +3228,23 @@ child: CachedNetworkImage( ), ), ), + if (showSafRepaired) + Positioned( + left: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.build, + size: 12, + color: colorScheme.onTertiaryContainer, + ), + ), + ), if (fileExists && !_isSelectionMode) Positioned( right: 4, diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index deea588b..84b872ac 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -8,6 +8,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; class DownloadSettingsPage extends ConsumerStatefulWidget { @@ -680,9 +681,17 @@ class _DownloadSettingsPageState extends ConsumerState { if (Platform.isIOS) { _showIOSDirectoryOptions(context, ref); } else { - final result = await FilePicker.platform.getDirectoryPath(); + final result = await PlatformBridge.pickSafTree(); if (result != null) { - ref.read(settingsProvider.notifier).setDownloadDirectory(result); + final treeUri = result['tree_uri'] as String? ?? ''; + final displayName = result['display_name'] as String? ?? ''; + if (treeUri.isNotEmpty) { + ref.read(settingsProvider.notifier).setStorageMode('saf'); + ref.read(settingsProvider.notifier).setDownloadTreeUri( + treeUri, + displayName: displayName.isNotEmpty ? displayName : treeUri, + ); + } } } } @@ -899,6 +908,8 @@ ListTile( switch (format) { case 'mp3_320': return 'MP3 320kbps'; + case 'opus_256': + return 'Opus 256kbps'; case 'opus_128': return 'Opus 128kbps'; default: @@ -951,10 +962,20 @@ ListTile( Navigator.pop(context); }, ), + ListTile( + leading: const Icon(Icons.graphic_eq), + title: const Text('Opus 256kbps'), + subtitle: const Text('Best quality Opus, ~8MB per track'), + trailing: current == 'opus_256' ? Icon(Icons.check, color: colorScheme.primary) : null, + onTap: () { + ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256'); + Navigator.pop(context); + }, + ), ListTile( leading: const Icon(Icons.graphic_eq), title: const Text('Opus 128kbps'), - subtitle: const Text('Modern codec, ~4MB per track'), + subtitle: const Text('Smallest size, ~4MB per track'), trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null, onTap: () { ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128'); @@ -1167,6 +1188,7 @@ class _ServiceSelector extends ConsumerWidget { label: 'Amazon', isSelected: effectiveService == 'amazon', isDisabled: true, + disabledReason: 'Coming soon', onTap: () {}, ), ], diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index a78d4848..a749cd64 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -117,7 +117,8 @@ class _LibrarySettingsPageState extends ConsumerState { return; } - if (!await Directory(libraryPath).exists()) { + if (!libraryPath.startsWith('content://') && + !await Directory(libraryPath).exists()) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.libraryFolderNotExist)), @@ -290,6 +291,53 @@ class _LibrarySettingsPageState extends ConsumerState { SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.libraryActions), ), + if (libraryState.scanWasCancelled) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.tertiaryContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.warning_amber_outlined, + color: colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Scan cancelled', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onTertiaryContainer, + ), + ), + const SizedBox(height: 2), + Text( + 'You can retry the scan when ready.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + TextButton( + onPressed: _startScan, + child: Text(context.l10n.dialogRetry), + ), + ], + ), + ), + ), + ), SliverToBoxAdapter( child: SettingsGroup( children: [ diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 0a80e0bd..25958832 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -9,6 +9,7 @@ import 'package:go_router/go_router.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; class SetupScreen extends ConsumerStatefulWidget { const SetupScreen({super.key}); @@ -22,6 +23,7 @@ class _SetupScreenState extends ConsumerState { bool _storagePermissionGranted = false; bool _notificationPermissionGranted = false; String? _selectedDirectory; + String? _selectedTreeUri; bool _isLoading = false; int _androidSdkVersion = 0; @@ -246,13 +248,19 @@ class _SetupScreenState extends ConsumerState { if (Platform.isIOS) { await _showIOSDirectoryOptions(); } else { - String? selectedDirectory = await FilePicker.platform.getDirectoryPath( - dialogTitle: context.l10n.setupSelectDownloadFolder, - ); + final result = await PlatformBridge.pickSafTree(); + if (result != null) { + final treeUri = result['tree_uri'] as String? ?? ''; + final displayName = result['display_name'] as String? ?? ''; + if (treeUri.isNotEmpty) { + setState(() { + _selectedTreeUri = treeUri; + _selectedDirectory = displayName.isNotEmpty ? displayName : treeUri; + }); + } + } - if (selectedDirectory != null) { - setState(() => _selectedDirectory = selectedDirectory); - } else { + if (_selectedTreeUri == null || _selectedTreeUri!.isEmpty) { final defaultDir = await _getDefaultDirectory(); if (mounted) { final useDefault = await showDialog( @@ -268,7 +276,10 @@ class _SetupScreenState extends ConsumerState { ); if (useDefault == true) { - setState(() => _selectedDirectory = defaultDir); + setState(() { + _selectedTreeUri = ''; + _selectedDirectory = defaultDir; + }); } } } @@ -387,12 +398,21 @@ class _SetupScreenState extends ConsumerState { setState(() => _isLoading = true); try { - final dir = Directory(_selectedDirectory!); - if (!await dir.exists()) { - await dir.create(recursive: true); + if (!Platform.isAndroid || _selectedTreeUri == null || _selectedTreeUri!.isEmpty) { + final dir = Directory(_selectedDirectory!); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + ref.read(settingsProvider.notifier).setStorageMode('app'); + ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!); + ref.read(settingsProvider.notifier).setDownloadTreeUri(''); + } else { + ref.read(settingsProvider.notifier).setStorageMode('saf'); + ref.read(settingsProvider.notifier).setDownloadTreeUri( + _selectedTreeUri!, + displayName: _selectedDirectory, + ); } - - ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!); if (_useSpotifyApi && _clientIdController.text.trim().isNotEmpty && diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 55727e06..92429da2 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -3,11 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/palette_service.dart'; -import 'package:spotiflac_android/utils/mime_utils.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -128,9 +127,9 @@ class _TrackMetadataScreenState extends ConsumerState { bool exists = false; int? size; try { - final stat = await FileStat.stat(filePath); - exists = stat.type != FileSystemEntityType.notFound; - if (exists) { + final stat = await fileStat(filePath); + if (stat != null) { + exists = true; size = stat.size; } } catch (_) {} @@ -1212,10 +1211,7 @@ class _TrackMetadataScreenState extends ConsumerState { if (_isLocalItem) { // For local items, just delete the file try { - final file = File(cleanFilePath); - if (await file.exists()) { - await file.delete(); - } + await deleteFile(cleanFilePath); } catch (e) { debugPrint('Failed to delete file: $e'); } @@ -1224,10 +1220,7 @@ class _TrackMetadataScreenState extends ConsumerState { } else { // Existing download history deletion logic try { - final file = File(cleanFilePath); - if (await file.exists()) { - await file.delete(); - } + await deleteFile(cleanFilePath); } catch (e) { debugPrint('Failed to delete file: $e'); } @@ -1249,13 +1242,7 @@ class _TrackMetadataScreenState extends ConsumerState { Future _openFile(BuildContext context, String filePath) async { try { - final mimeType = audioMimeTypeForPath(filePath); - final result = await OpenFilex.open(filePath, type: mimeType); - if (result.type != ResultType.done && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))), - ); - } + await openFile(filePath); } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1276,8 +1263,8 @@ class _TrackMetadataScreenState extends ConsumerState { } Future _shareFile(BuildContext context) async { - final file = File(cleanFilePath); - if (!await file.exists()) { + String sharePath = cleanFilePath; + if (!await fileExists(sharePath)) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarFileNotFound)), @@ -1285,10 +1272,23 @@ class _TrackMetadataScreenState extends ConsumerState { } return; } + + if (isContentUri(sharePath)) { + final tempPath = await PlatformBridge.copyContentUriToTemp(sharePath); + if (tempPath == null || tempPath.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to prepare file for sharing'))), + ); + } + return; + } + sharePath = tempPath; + } await SharePlus.instance.share( ShareParams( - files: [XFile(cleanFilePath)], + files: [XFile(sharePath)], text: '$trackName - $artistName', ), ); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 7a6e782f..04c291ef 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -34,7 +34,7 @@ class HistoryDatabase { return await openDatabase( path, - version: 1, + version: 3, onCreate: _createDB, onUpgrade: _upgradeDB, ); @@ -52,6 +52,11 @@ class HistoryDatabase { album_artist TEXT, cover_url TEXT, file_path TEXT NOT NULL, + storage_mode TEXT, + download_tree_uri TEXT, + saf_relative_dir TEXT, + saf_file_name TEXT, + saf_repaired INTEGER, service TEXT NOT NULL, downloaded_at TEXT NOT NULL, isrc TEXT, @@ -80,7 +85,15 @@ class HistoryDatabase { Future _upgradeDB(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading database from v$oldVersion to v$newVersion'); - // Future migrations go here + if (oldVersion < 2) { + await db.execute('ALTER TABLE history ADD COLUMN storage_mode TEXT'); + await db.execute('ALTER TABLE history ADD COLUMN download_tree_uri TEXT'); + await db.execute('ALTER TABLE history ADD COLUMN saf_relative_dir TEXT'); + await db.execute('ALTER TABLE history ADD COLUMN saf_file_name TEXT'); + } + if (oldVersion < 3) { + await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER'); + } } // ==================== iOS Path Normalization ==================== @@ -244,6 +257,11 @@ class HistoryDatabase { 'album_artist': json['albumArtist'], 'cover_url': json['coverUrl'], 'file_path': json['filePath'], + 'storage_mode': json['storageMode'], + 'download_tree_uri': json['downloadTreeUri'], + 'saf_relative_dir': json['safRelativeDir'], + 'saf_file_name': json['safFileName'], + 'saf_repaired': json['safRepaired'] == true ? 1 : 0, 'service': json['service'], 'downloaded_at': json['downloadedAt'], 'isrc': json['isrc'], @@ -272,6 +290,11 @@ class HistoryDatabase { 'albumArtist': row['album_artist'], 'coverUrl': row['cover_url'], 'filePath': _normalizeIosPath(row['file_path'] as String?), + 'storageMode': row['storage_mode'], + 'downloadTreeUri': row['download_tree_uri'], + 'safRelativeDir': row['saf_relative_dir'], + 'safFileName': row['saf_file_name'], + 'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true, 'service': row['service'], 'downloadedAt': row['downloaded_at'], 'isrc': row['isrc'], diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 34f910eb..71ec4a39 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -1,8 +1,8 @@ -import 'dart:io'; import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/utils/file_access.dart'; final _log = AppLogger('LibraryDatabase'); @@ -341,7 +341,7 @@ class LibraryDatabase { int removed = 0; for (final row in rows) { final filePath = row['file_path'] as String; - if (!await File(filePath).exists()) { + if (!await fileExists(filePath)) { await db.delete('library', where: 'id = ?', whereArgs: [row['id']]); removed++; } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 265801cb..d51569a1 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -67,6 +67,11 @@ class PlatformBridge { String? releaseDate, String? itemId, int durationMs = 0, + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', }) async { _log.i('downloadTrack: "$trackName" by $artistName via $service'); final request = jsonEncode({ @@ -89,6 +94,11 @@ class PlatformBridge { 'release_date': releaseDate ?? '', 'item_id': itemId ?? '', 'duration_ms': durationMs, + 'storage_mode': storageMode, + 'saf_tree_uri': safTreeUri, + 'saf_relative_dir': safRelativeDir, + 'saf_file_name': safFileName, + 'saf_output_ext': safOutputExt, }); final result = await _channel.invokeMethod('downloadTrack', request); @@ -125,6 +135,11 @@ class PlatformBridge { String? label, String? copyright, String lyricsMode = 'embed', + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', }) async { _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); final request = jsonEncode({ @@ -151,6 +166,11 @@ class PlatformBridge { 'label': label ?? '', 'copyright': copyright ?? '', 'lyrics_mode': lyricsMode, + 'storage_mode': storageMode, + 'saf_tree_uri': safTreeUri, + 'saf_relative_dir': safRelativeDir, + 'saf_file_name': safFileName, + 'saf_output_ext': safOutputExt, }); final result = await _channel.invokeMethod('downloadWithFallback', request); @@ -225,6 +245,80 @@ class PlatformBridge { return result as String; } + static Future?> pickSafTree() async { + final result = await _channel.invokeMethod('pickSafTree'); + if (result == null) return null; + return jsonDecode(result as String) as Map; + } + + static Future safExists(String uri) async { + final result = await _channel.invokeMethod('safExists', {'uri': uri}); + return result as bool; + } + + static Future safDelete(String uri) async { + final result = await _channel.invokeMethod('safDelete', {'uri': uri}); + return result as bool; + } + + static Future> safStat(String uri) async { + final result = await _channel.invokeMethod('safStat', {'uri': uri}); + return jsonDecode(result as String) as Map; + } + + static Future> resolveSafFile({ + required String treeUri, + required String fileName, + String relativeDir = '', + }) async { + final result = await _channel.invokeMethod('resolveSafFile', { + 'tree_uri': treeUri, + 'relative_dir': relativeDir, + 'file_name': fileName, + }); + return jsonDecode(result as String) as Map; + } + + static Future copyContentUriToTemp(String uri) async { + final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri}); + return result as String?; + } + + static Future replaceContentUriFromPath( + String uri, + String srcPath, + ) async { + final result = await _channel.invokeMethod('safReplaceFromPath', { + 'uri': uri, + 'src_path': srcPath, + }); + return result as bool; + } + + static Future createSafFileFromPath({ + required String treeUri, + required String relativeDir, + required String fileName, + required String mimeType, + required String srcPath, + }) async { + final result = await _channel.invokeMethod('safCreateFromPath', { + 'tree_uri': treeUri, + 'relative_dir': relativeDir, + 'file_name': fileName, + 'mime_type': mimeType, + 'src_path': srcPath, + }); + return result as String?; + } + + static Future openContentUri(String uri, {String mimeType = ''}) async { + await _channel.invokeMethod('openContentUri', { + 'uri': uri, + 'mime_type': mimeType, + }); + } + static Future> fetchLyrics( String spotifyId, String trackName, @@ -593,6 +687,11 @@ static Future> downloadWithExtensions({ String? label, String lyricsMode = 'embed', String? preferredService, + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', }) async { _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}'); final request = jsonEncode({ @@ -619,6 +718,11 @@ static Future> downloadWithExtensions({ 'label': label ?? '', 'lyrics_mode': lyricsMode, 'service': preferredService ?? '', + 'storage_mode': storageMode, + 'saf_tree_uri': safTreeUri, + 'saf_relative_dir': safRelativeDir, + 'saf_file_name': safFileName, + 'saf_output_ext': safOutputExt, }); final result = await _channel.invokeMethod('downloadWithExtensions', request); @@ -852,6 +956,15 @@ static Future> downloadWithExtensions({ return list.map((e) => e as Map).toList(); } + static Future>> scanSafTree(String treeUri) async { + _log.i('scanSafTree: $treeUri'); + final result = await _channel.invokeMethod('scanSafTree', { + 'tree_uri': treeUri, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + /// Get current library scan progress static Future> getLibraryScanProgress() async { final result = await _channel.invokeMethod('getLibraryScanProgress'); @@ -889,6 +1002,23 @@ static Future> downloadWithExtensions({ return jsonDecode(result as String) as Map; } + static Future> runPostProcessingV2( + String filePath, { + Map? metadata, + }) async { + final input = {}; + if (filePath.startsWith('content://')) { + input['uri'] = filePath; + } else { + input['path'] = filePath; + } + final result = await _channel.invokeMethod('runPostProcessingV2', { + 'input': jsonEncode(input), + 'metadata': metadata != null ? jsonEncode(metadata) : '', + }); + return jsonDecode(result as String) as Map; + } + static Future>> getPostProcessingProviders() async { final result = await _channel.invokeMethod('getPostProcessingProviders'); final list = jsonDecode(result as String) as List; diff --git a/lib/utils/file_access.dart b/lib/utils/file_access.dart new file mode 100644 index 00000000..e0227719 --- /dev/null +++ b/lib/utils/file_access.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/utils/mime_utils.dart'; + +class FileAccessStat { + final int? size; + final DateTime? modified; + + const FileAccessStat({this.size, this.modified}); +} + +bool isContentUri(String? path) { + return path != null && path.startsWith('content://'); +} + +Future fileExists(String? path) async { + if (path == null || path.isEmpty) return false; + if (isContentUri(path)) { + return PlatformBridge.safExists(path); + } + return File(path).exists(); +} + +Future deleteFile(String? path) async { + if (path == null || path.isEmpty) return; + if (isContentUri(path)) { + await PlatformBridge.safDelete(path); + return; + } + try { + await File(path).delete(); + } catch (_) {} +} + +Future fileStat(String? path) async { + if (path == null || path.isEmpty) return null; + if (isContentUri(path)) { + final stat = await PlatformBridge.safStat(path); + final exists = stat['exists'] as bool? ?? true; + if (!exists) return null; + return FileAccessStat( + size: stat['size'] as int?, + modified: stat['modified'] != null + ? DateTime.fromMillisecondsSinceEpoch(stat['modified'] as int) + : null, + ); + } + + final stat = await FileStat.stat(path); + if (stat.type == FileSystemEntityType.notFound) return null; + return FileAccessStat(size: stat.size, modified: stat.modified); +} + +Future openFile(String path) async { + if (isContentUri(path)) { + await PlatformBridge.openContentUri(path, mimeType: ''); + return; + } + final mimeType = audioMimeTypeForPath(path); + final result = await OpenFilex.open(path, type: mimeType); + if (result.type != ResultType.done) { + throw Exception(result.message); + } +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 591f07ac..6a4f3fa9 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -334,6 +334,28 @@ Padding( widget.onSelect('HIGH', _selectedService); }, ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20), + ), + title: const Text('Opus 256kbps'), + subtitle: const Text('Best quality Opus, ~8MB per track'), + trailing: currentFormat == 'opus_256' + ? Icon(Icons.check_circle, color: colorScheme.primary) + : null, + onTap: () { + ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256'); + Navigator.pop(modalContext); // Close format picker + Navigator.pop(context); // Close service picker + widget.onSelect('HIGH', _selectedService); + }, + ), ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: Container( @@ -345,7 +367,7 @@ Padding( child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20), ), title: const Text('Opus 128kbps'), - subtitle: const Text('Modern codec, ~4MB per track'), + subtitle: const Text('Smallest size, ~4MB per track'), trailing: currentFormat == 'opus_128' ? Icon(Icons.check_circle, color: colorScheme.primary) : null,