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
This commit is contained in:
zarzet
2026-02-06 07:09:57 +07:00
parent 7ade57e010
commit 278ebf3472
30 changed files with 2489 additions and 389 deletions
+37 -1
View File
@@ -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)
*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
+1
View File
@@ -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")
}
@@ -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<Pair<DocumentFile, String>> = 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<Pair<DocumentFile, String>>()
val queue: ArrayDeque<Pair<DocumentFile, String>> = 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<String>("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<String>("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<String>("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<String>("tree_uri") ?: ""
val relativeDir = call.argument<String>("relative_dir") ?: ""
val fileName = call.argument<String>("file_name") ?: ""
val response = withContext(Dispatchers.IO) {
resolveSafFile(treeUriStr, relativeDir, fileName)
}
result.success(response)
}
"safCopyToTemp" -> {
val uriStr = call.argument<String>("uri") ?: ""
val tempPath = withContext(Dispatchers.IO) {
copyUriToTemp(Uri.parse(uriStr))
}
result.success(tempPath)
}
"safReplaceFromPath" -> {
val uriStr = call.argument<String>("uri") ?: ""
val srcPath = call.argument<String>("src_path") ?: ""
val ok = withContext(Dispatchers.IO) {
writeUriFromPath(Uri.parse(uriStr), srcPath)
}
result.success(ok)
}
"safCreateFromPath" -> {
val treeUriStr = call.argument<String>("tree_uri") ?: ""
val relativeDir = call.argument<String>("relative_dir") ?: ""
val fileName = call.argument<String>("file_name") ?: ""
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
val srcPath = call.argument<String>("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<String>("uri") ?: ""
val mimeType = call.argument<String>("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<String>("spotify_id") ?: ""
val trackName = call.argument<String>("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<String>("file_path") ?: ""
val metadataJson = call.argument<String>("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<String>("input") ?: ""
val metadataJson = call.argument<String>("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<String>("folder_path") ?: ""
val response = withContext(Dispatchers.IO) {
safScanActive = false
Gobackend.scanLibraryFolderJSON(folderPath)
}
result.success(response)
}
"scanSafTree" -> {
val treeUri = call.argument<String>("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)
+26 -10
View File
@@ -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
}
+60 -2
View File
@@ -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()
+146 -1
View File
@@ -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
}
+25 -9
View File
@@ -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
}
+74 -44
View File
@@ -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{
+8
View File
@@ -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)
+9 -1
View File
@@ -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,
+4
View File
@@ -11,6 +11,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$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,
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -17,6 +17,7 @@ class LocalLibraryState {
final String? scanCurrentFile;
final int scanTotalFiles;
final int scanErrorCount;
final bool scanWasCancelled;
final DateTime? lastScannedAt;
final Set<String> _isrcSet;
final Set<String> _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<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
Timer? _progressTimer;
bool _isLoaded = false;
bool _scanCancelRequested = false;
@override
LocalLibraryState build() {
@@ -142,6 +147,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
_scanCancelRequested = false;
_log.i('Starting library scan: $folderPath');
state = state.copyWith(
isScanning: true,
@@ -149,6 +155,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanCurrentFile: null,
scanTotalFiles: 0,
scanErrorCount: 0,
scanWasCancelled: false,
);
try {
@@ -163,7 +170,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_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 = <LocalLibraryItem>[];
for (final json in results) {
@@ -187,12 +201,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
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<LocalLibraryState> {
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();
}
+19
View File
@@ -48,6 +48,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
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<AppSettings> {
_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();
+3 -3
View File
@@ -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))));
}
+3 -3
View File
@@ -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))),
+3 -9
View File
@@ -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<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
Future<void> _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(
+3 -2
View File
@@ -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))),
+3 -8
View File
@@ -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<LocalAlbumScreen> {
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<LocalAlbumScreen> {
Future<void> _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(
+3 -3
View File
@@ -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))));
}
+86 -9
View File
@@ -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<QueueTab> {
String _localFilterQueryCache = '';
List<LocalLibraryItem> _filteredLocalItemsCache = const [];
final Map<String, _UnifiedCacheEntry> _unifiedItemsCache = {};
bool _showSafRepairedBadge = false;
// Advanced filters
String? _filterSource; // null = all, 'downloaded', 'local'
@@ -637,10 +637,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
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<QueueTab> {
}
_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<QueueTab> {
Future<void> _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<String, int> albumCounts,
required Map<String, int> localAlbumCounts,
required List<LocalLibraryItem> 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,
@@ -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<DownloadSettingsPage> {
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: () {},
),
],
@@ -117,7 +117,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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: [
+32 -12
View File
@@ -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<SetupScreen> {
bool _storagePermissionGranted = false;
bool _notificationPermissionGranted = false;
String? _selectedDirectory;
String? _selectedTreeUri;
bool _isLoading = false;
int _androidSdkVersion = 0;
@@ -246,13 +248,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
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<bool>(
@@ -268,7 +276,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
);
if (useDefault == true) {
setState(() => _selectedDirectory = defaultDir);
setState(() {
_selectedTreeUri = '';
_selectedDirectory = defaultDir;
});
}
}
}
@@ -387,12 +398,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
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 &&
+23 -23
View File
@@ -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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
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<TrackMetadataScreen> {
} 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<TrackMetadataScreen> {
Future<void> _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<TrackMetadataScreen> {
}
Future<void> _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<TrackMetadataScreen> {
}
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',
),
);
+25 -2
View File
@@ -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<void> _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'],
+2 -2
View File
@@ -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++;
}
+130
View File
@@ -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<Map<String, dynamic>?> pickSafTree() async {
final result = await _channel.invokeMethod('pickSafTree');
if (result == null) return null;
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<bool> safExists(String uri) async {
final result = await _channel.invokeMethod('safExists', {'uri': uri});
return result as bool;
}
static Future<bool> safDelete(String uri) async {
final result = await _channel.invokeMethod('safDelete', {'uri': uri});
return result as bool;
}
static Future<Map<String, dynamic>> safStat(String uri) async {
final result = await _channel.invokeMethod('safStat', {'uri': uri});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<String?> copyContentUriToTemp(String uri) async {
final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri});
return result as String?;
}
static Future<bool> replaceContentUriFromPath(
String uri,
String srcPath,
) async {
final result = await _channel.invokeMethod('safReplaceFromPath', {
'uri': uri,
'src_path': srcPath,
});
return result as bool;
}
static Future<String?> 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<void> openContentUri(String uri, {String mimeType = ''}) async {
await _channel.invokeMethod('openContentUri', {
'uri': uri,
'mime_type': mimeType,
});
}
static Future<Map<String, dynamic>> fetchLyrics(
String spotifyId,
String trackName,
@@ -593,6 +687,11 @@ static Future<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> downloadWithExtensions({
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<List<Map<String, dynamic>>> 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<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress');
@@ -889,6 +1002,23 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> runPostProcessingV2(
String filePath, {
Map<String, dynamic>? metadata,
}) async {
final input = <String, dynamic>{};
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<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> getPostProcessingProviders() async {
final result = await _channel.invokeMethod('getPostProcessingProviders');
final list = jsonDecode(result as String) as List<dynamic>;
+66
View File
@@ -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<bool> fileExists(String? path) async {
if (path == null || path.isEmpty) return false;
if (isContentUri(path)) {
return PlatformBridge.safExists(path);
}
return File(path).exists();
}
Future<void> 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<FileAccessStat?> 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<void> 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);
}
}
+23 -1
View File
@@ -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,