mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-25 17:17:52 +02:00
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:
+37
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user