Compare commits

...

20 Commits

Author SHA1 Message Date
zarzet 0f327cd1f6 feat: add flat singles folder option (Artist/song.flac without Singles subfolder) 2026-03-26 18:15:37 +07:00
zarzet 4f2e677e8b fix: improve skeleton visibility and artist header for light mode 2026-03-26 17:32:54 +07:00
zarzet 79a69f8f70 chore: clean up codebase 2026-03-26 16:43:56 +07:00
zarzet bf0f4bdf3e fix: store URL input flash on startup and FLAC metadata fallback for mismatched files
Load saved registry URL before first state update to prevent brief
flash of the setup screen when the store tab initializes.

Add Ogg/Opus fallback in readFileMetadata when FLAC parsing fails,
handling files saved with .flac extension that contain opus data.
2026-03-26 16:26:14 +07:00
zarzet 5e1cc3ecb5 refactor: extract YouTube download to ytmusic extension and fix UI issues
Remove built-in YouTube/Cobalt download pipeline from Go backend and
Dart frontend. YouTube downloading now requires the ytmusic-spotiflac
extension (with download_provider capability).

Go backend:
- Delete youtube.go (745 lines) and youtube_quality_test.go
- Remove DownloadFromYouTube, IsYouTubeURLExport,
  ExtractYouTubeVideoIDExport from exports.go
- Remove YouTube routing from DownloadTrack and DownloadByStrategy

Dart frontend:
- Remove YouTube from built-in services, bitrate settings, quality UI
- Remove youtubeOpusBitrate/youtubeMp3Bitrate from settings model
- Add migration 7: default service youtube -> tidal
- Remove YouTube l10n keys from all 14 arb files and regenerate
- Update _determineOutputExt to handle opus_/mp3_ quality strings
- Add SAF opus/mp3 metadata embedding in unified branch
- Fix TweenSequence assertion crash (t outside 0.0-1.0)
- Fix store URL TextField styling consistency

Extension changes (gitignored, in extension/YT-Music-SpotiFLAC/):
- Add download_provider type, qualityOptions, network permissions
- Implement checkAvailability and download via SpotubeDL/Cobalt
2026-03-26 16:17:57 +07:00
zarzet d4b37edc2f feat: add animation utilities and fix regressions in UI refactor
- Add animation_utils.dart with skeleton loaders, staggered list animations,
  animated checkboxes, badge bump, download success overlay, and shared
  page route helper
- Replace CircularProgressIndicator with shimmer skeleton loaders across
  album, artist, playlist, search, store, and extension screens
- Unify page transitions via slidePageRoute (MaterialPageRoute) for
  Android predictive back gesture support
- Extract AnimatedSelectionCheckbox with configurable unselectedColor
  to preserve original transparent/opaque backgrounds per context
- Add swipe-to-dismiss on download queue items with confirmDismiss
  dialog for active downloads to prevent accidental cancellation
- Add Hero animations for cover art transitions between list and detail
- Add AnimatedBadge bump on navigation bar badge count changes
- Add DownloadSuccessOverlay green flash on download completion
- Restore fine-grained ref.watch(.select()) in _CollectionTrackTile
  to avoid full list rebuilds on download history changes
- Fix DownloadSuccessOverlay re-flashing on widget recreation by
  initialising _wasSuccess from initial widget state
- Remove orphan Hero tag in search_screen that had no matching pair
- Chip borderRadius updated from 8 to 20 for consistency
2026-03-26 13:38:07 +07:00
zarzet 9483614bc7 feat: cache audio analysis results and fix total samples metric 2026-03-26 02:17:18 +07:00
zarzet a73f2e1a13 feat: auto-select recommended download service based on content source 2026-03-26 01:44:11 +07:00
zarzet 091e3fadd9 feat: add audio quality analysis widget and fix USLT lyrics detection 2026-03-26 01:11:29 +07:00
zarzet 5340ca7b16 chore: bump version to 4.1.0+117 2026-03-25 23:23:14 +07:00
zarzet 85d3e58a26 fix: hi-res cover art for Tidal/Qobuz and album metadata override 2026-03-25 23:17:45 +07:00
zarzet 1125c757fe fix: remove unintended home reset on tab switch 2026-03-25 22:33:04 +07:00
zarzet 66d714d368 fix: unify search bar, filter chips, tab navigation, and clean up comments 2026-03-25 22:27:22 +07:00
zarzet 49c2501fbc refactor: use pointer returns and unified forceRefresh in ExtensionStore 2026-03-25 21:47:31 +07:00
zarzet e487817f21 feat: add sorting options for search results 2026-03-25 21:40:36 +07:00
zarzet d8bbeb1e67 perf: use Tidal/Qobuz metadata for Deezer track resolution 2026-03-25 21:18:47 +07:00
zarzet 9693616645 fix: route tidal/qobuz items from Recent Access to built-in screens instead of extension screens 2026-03-25 20:50:33 +07:00
zarzet 0423e36d34 chore: bump version to 3.9.1+116 2026-03-25 20:08:53 +07:00
zarzet c8d605fdee fix: add ValueListenableBuilder for embedded cover refresh and localize hardcoded queue strings 2026-03-25 20:05:24 +07:00
zarzet 03fd734048 perf: lazy extension VM init, incremental startup maintenance, and UI optimizations
- Defer extension VM initialization until first use with lockReadyVM() pattern to eliminate TOCTOU races and reduce startup overhead
- Add validateExtensionLoad() to catch JS errors at install time without keeping VM alive
- Teardown VM on extension disable to free resources; re-init lazily on re-enable
- Replace full orphan cleanup with incremental cursor-based pagination across launches
- Batch DB writes (upsertBatch, replaceAll) with transactions for atomicity
- Parse JSON natively on Kotlin side to avoid double-serialization over MethodChannel
- Add identity-based memoization caches for unified items and path match keys in queue tab
- Use ValueListenableBuilder for targeted embedded cover refreshes instead of full setState
- Extract shared widgets (_buildAlbumGridItemCore, _buildFilterButton, _navigateWithUnfocus)
- Use libraryCollectionsProvider selector and MediaQuery.paddingOf for fewer rebuilds
- Simplify supporter chip tiers and localize remaining hardcoded strings
2026-03-25 19:55:02 +07:00
104 changed files with 7346 additions and 4215 deletions
+5 -5
View File
@@ -141,6 +141,11 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link
</details> </details>
> [!NOTE]
> If SpotiFLAC is useful to you, consider supporting development:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
--- ---
## Contributors ## Contributors
@@ -165,10 +170,5 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md)
| [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | | [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) |
| [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | | | [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | |
> [!NOTE]
> If SpotiFLAC is useful to you, consider supporting development:
>
> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
> [!TIP] > [!TIP]
> **Star the repo** to get notified about all new releases directly from GitHub. > **Star the repo** to get notified about all new releases directly from GitHub.
@@ -137,14 +137,13 @@ class DownloadService : Service() {
private fun startForegroundService() { private fun startForegroundService() {
isRunning = true isRunning = true
// Acquire wake lock to prevent CPU sleep
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock( wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, PowerManager.PARTIAL_WAKE_LOCK,
WAKELOCK_TAG WAKELOCK_TAG
).apply { ).apply {
acquire(60 * 60 * 1000L) // 1 hour max acquire(60 * 60 * 1000L)
} }
val notification = buildNotification(0, 0) val notification = buildNotification(0, 0)
@@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.json.JSONTokener
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -129,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() {
) )
companion object { companion object {
// Minimum API level we consider "safe" for Impeller (Android 10+)
private const val SAFE_API_FOR_IMPELLER = 29 private const val SAFE_API_FOR_IMPELLER = 29
// Known problematic GPU patterns (lowercase)
private val PROBLEMATIC_GPU_PATTERNS = listOf( private val PROBLEMATIC_GPU_PATTERNS = listOf(
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm "adreno (tm) 3",
"adreno (tm) 4", // Adreno 400 series - some have issues "adreno (tm) 4",
"mali-4", // Mali-400 series - old ARM GPUs "mali-4",
"mali-t6", // Mali-T600 series "mali-t6",
"mali-t7", // Mali-T700 series (some) "mali-t7",
"powervr sgx", // PowerVR SGX series - old Imagination GPUs "powervr sgx",
"powervr ge8320", // PowerVR GE8320 - known issues "powervr ge8320",
"gc1000", // Vivante GC1000 "gc1000",
"gc2000", // Vivante GC2000 "gc2000",
) )
// Known problematic chipsets/hardware (lowercase)
private val PROBLEMATIC_CHIPSETS = listOf( private val PROBLEMATIC_CHIPSETS = listOf(
"mt6762", // MediaTek Helio P22 with PowerVR GE8320 "mt6762",
"mt6765", // MediaTek Helio P35 with PowerVR GE8320 "mt6765",
"mt8768", // MediaTek tablet chip "mt8768",
"mp0873", // MediaTek variant "mp0873",
"msm8974", // Snapdragon 800/801 with Adreno 330 "msm8974",
"msm8226", // Snapdragon 400 with Adreno 305 "msm8226",
"msm8926", // Snapdragon 400 with Adreno 305 "msm8926",
"apq8084", // Snapdragon 805 (some issues) "apq8084",
) )
// Known problematic device models (lowercase)
private val PROBLEMATIC_MODELS = listOf( private val PROBLEMATIC_MODELS = listOf(
"sm-t220", // Samsung Tab A7 Lite "sm-t220",
"sm-t225", // Samsung Tab A7 Lite LTE "sm-t225",
"hammerhead", // Nexus 5 (Adreno 330) "hammerhead",
) )
/** /**
* Check if device should use Skia instead of Impeller. * Check if device should use Skia instead of Impeller.
@@ -173,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() {
val model = Build.MODEL.lowercase(Locale.ROOT) val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT) val device = Build.DEVICE.lowercase(Locale.ROOT)
// 1. Check for explicitly problematic device models
for (problematicModel in PROBLEMATIC_MODELS) { for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) { if (model.contains(problematicModel) || device.contains(problematicModel)) {
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel") android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
@@ -181,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// 2. Check for problematic chipsets
for (chipset in PROBLEMATIC_CHIPSETS) { for (chipset in PROBLEMATIC_CHIPSETS) {
if (hardware.contains(chipset) || board.contains(chipset)) { if (hardware.contains(chipset) || board.contains(chipset)) {
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset") android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
@@ -189,12 +184,9 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// 3. For Android < 10 (API 29), be more aggressive about disabling Impeller
if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) { if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) {
// For older Android, check GPU renderer if available
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
// Check for known problematic GPUs
for (pattern in PROBLEMATIC_GPU_PATTERNS) { for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) { if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern") android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
@@ -202,14 +194,12 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// For very old Android (< 8.0), always use Skia as Vulkan support is spotty
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety") android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
return true return true
} }
} }
// 4. For Android 10+, still check for known problematic GPUs
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
for (pattern in PROBLEMATIC_GPU_PATTERNS) { for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) { if (gpuRenderer.contains(pattern)) {
@@ -227,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() {
*/ */
private fun getGpuRenderer(): String { private fun getGpuRenderer(): String {
return try { return try {
// This might not work before GL context is created,
// but worth trying for additional detection
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: "" android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
} catch (e: Exception) { } catch (e: Exception) {
"" ""
@@ -413,6 +401,38 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
private fun parseJsonValue(value: Any?): Any? {
return when (value) {
null, JSONObject.NULL -> null
is JSONObject -> {
val map = LinkedHashMap<String, Any?>()
val keys = value.keys()
while (keys.hasNext()) {
val key = keys.next()
map[key] = parseJsonValue(value.opt(key))
}
map
}
is JSONArray -> {
val list = ArrayList<Any?>()
for (i in 0 until value.length()) {
list.add(parseJsonValue(value.opt(i)))
}
list
}
is Number, is Boolean, is String -> value
else -> value.toString()
}
}
private fun parseJsonPayload(payload: String): Any {
return try {
parseJsonValue(JSONTokener(payload).nextValue()) ?: payload
} catch (_: Exception) {
payload
}
}
private fun startDownloadProgressStream(sink: EventChannel.EventSink) { private fun startDownloadProgressStream(sink: EventChannel.EventSink) {
stopDownloadProgressStream() stopDownloadProgressStream()
downloadProgressEventSink = sink downloadProgressEventSink = sink
@@ -425,7 +445,7 @@ class MainActivity: FlutterFragmentActivity() {
} }
if (payload != lastDownloadProgressPayload) { if (payload != lastDownloadProgressPayload) {
lastDownloadProgressPayload = payload lastDownloadProgressPayload = payload
sink.success(payload) sink.success(parseJsonPayload(payload))
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w( android.util.Log.w(
@@ -457,7 +477,7 @@ class MainActivity: FlutterFragmentActivity() {
} }
if (payload != lastLibraryScanProgressPayload) { if (payload != lastLibraryScanProgressPayload) {
lastLibraryScanProgressPayload = payload lastLibraryScanProgressPayload = payload
sink.success(payload) sink.success(parseJsonPayload(payload))
} }
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w( android.util.Log.w(
@@ -599,7 +619,6 @@ class MainActivity: FlutterFragmentActivity() {
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE. * Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
*/ */
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String { private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
// Try DISPLAY_NAME first
try { try {
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
@@ -610,7 +629,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
} catch (_: Exception) {} } catch (_: Exception) {}
// Try MIME_TYPE
try { try {
val mime = contentResolver.getType(uri) val mime = contentResolver.getType(uri)
val ext = extFromMimeType(mime) val ext = extFromMimeType(mime)
@@ -836,8 +854,6 @@ class MainActivity: FlutterFragmentActivity() {
val mimeType = mimeTypeForExt(outputExt) val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt) val fileName = buildSafFileName(req, outputExt)
// Check for existing file WITHOUT creating the directory first.
// This prevents empty folders from being created for duplicate downloads.
val existingDir = findDocumentDir(treeUri, relativeDir) val existingDir = findDocumentDir(treeUri, relativeDir)
if (existingDir != null) { if (existingDir != null) {
val existing = existingDir.findFile(fileName) val existing = existingDir.findFile(fileName)
@@ -852,7 +868,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// Only create the directory now that we know we need to download
val targetDir = ensureDocumentDir(treeUri, relativeDir) val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory") ?: return errorJson("Failed to access SAF directory")
@@ -875,7 +890,6 @@ class MainActivity: FlutterFragmentActivity() {
val respObj = JSONObject(response) val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) { if (respObj.optBoolean("success", false)) {
// Extension providers write to a local temp path instead of the SAF FD. // Extension providers write to a local temp path instead of the SAF FD.
// Copy the local file into the SAF document so it is not empty.
val goFilePath = respObj.optString("file_path", "") val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() && if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") && !goFilePath.startsWith("content://") &&
@@ -924,15 +938,10 @@ class MainActivity: FlutterFragmentActivity() {
try { try {
val docId = android.provider.DocumentsContract.getDocumentId(childUri) val docId = android.provider.DocumentsContract.getDocumentId(childUri)
if (docId.isNullOrEmpty()) return null if (docId.isNullOrEmpty()) return null
// Document IDs typically look like "primary:Music/Album/file.cue"
// Parent would be "primary:Music/Album"
val lastSlash = docId.lastIndexOf('/') val lastSlash = docId.lastIndexOf('/')
if (lastSlash <= 0) return null if (lastSlash <= 0) return null
val parentDocId = docId.substring(0, lastSlash) val parentDocId = docId.substring(0, lastSlash)
// Build a tree document URI for the parent so it supports listing/findFile
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri) val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
if (treeDocId.isNullOrEmpty()) return null if (treeDocId.isNullOrEmpty()) return null
@@ -957,21 +966,17 @@ class MainActivity: FlutterFragmentActivity() {
val lines = File(cueTempPath).readLines() val lines = File(cueTempPath).readLines()
for (line in lines) { for (line in lines) {
val trimmed = line.trim().let { l -> val trimmed = line.trim().let { l ->
// Strip BOM
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
} }
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
val rest = trimmed.substring(5).trim() val rest = trimmed.substring(5).trim()
// Parse: "filename" TYPE or filename TYPE
val filename = if (rest.startsWith("\"")) { val filename = if (rest.startsWith("\"")) {
val endQuote = rest.indexOf('"', 1) val endQuote = rest.indexOf('"', 1)
if (endQuote > 0) rest.substring(1, endQuote) else rest if (endQuote > 0) rest.substring(1, endQuote) else rest
} else { } else {
// Last word is the type, everything else is the filename
val parts = rest.split("\\s+".toRegex()) val parts = rest.split("\\s+".toRegex())
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
} }
// Return just the filename (strip any path separators)
return filename.substringAfterLast("/").substringAfterLast("\\") return filename.substringAfterLast("/").substringAfterLast("\\")
} }
} }
@@ -1056,7 +1061,6 @@ class MainActivity: FlutterFragmentActivity() {
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>() val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>() val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>() val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>() val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
@@ -1141,7 +1145,6 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0 var scanned = 0
var errors = traversalErrors var errors = traversalErrors
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
val cueReferencedAudioUris = mutableSetOf<String>() val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir) in cueFiles) { for ((cueDoc, parentDir) in cueFiles) {
@@ -1180,10 +1183,8 @@ class MainActivity: FlutterFragmentActivity() {
continue continue
} }
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString()) cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1197,7 +1198,6 @@ class MainActivity: FlutterFragmentActivity() {
continue continue
} }
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName) val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath) val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1240,14 +1240,12 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _) in audioFiles) { for ((doc, _) in audioFiles) {
if (safScanCancel) { if (safScanCancel) {
updateSafScanProgress { it.isComplete = true } updateSafScanProgress { it.isComplete = true }
return "[]" return "[]"
} }
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) { if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++ scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
@@ -1326,7 +1324,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString() return result.toString()
} }
// Parse existing files map: URI -> lastModified
val existingFiles = mutableMapOf<String, Long>() val existingFiles = mutableMapOf<String, Long>()
try { try {
val obj = JSONObject(existingFilesJson) val obj = JSONObject(existingFilesJson)
@@ -1345,20 +1342,15 @@ class MainActivity: FlutterFragmentActivity() {
} }
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
// CUE files to scan: (cueDoc, parentDir, lastModified)
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>() val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>() val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val currentUris = mutableSetOf<String>() val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>() val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>() val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
var traversalErrors = 0 var traversalErrors = 0
// Build a map of CUE base URIs -> existing virtual track URIs from the database. val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>()
// Virtual paths look like "content://...album.cue#track01".
// We need this to preserve virtual paths for unchanged CUE files.
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
for (key in existingFiles.keys) { for (key in existingFiles.keys) {
val hashIdx = key.indexOf("#track") val hashIdx = key.indexOf("#track")
if (hashIdx > 0) { if (hashIdx > 0) {
@@ -1367,7 +1359,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// Collect all files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque() val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "") queue.add(root to "")
@@ -1423,8 +1414,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
queue.add(child to childPath) queue.add(child to childPath)
} else if (child.isFile) { } else if (child.isFile) {
// Mark file as present first so it cannot be mis-classified as removed
// when provider-specific metadata calls (e.g., lastModified) fail.
val uriStr = child.uri.toString() val uriStr = child.uri.toString()
currentUris.add(uriStr) currentUris.add(uriStr)
@@ -1436,18 +1425,15 @@ class MainActivity: FlutterFragmentActivity() {
child.lastModified() child.lastModified()
} catch (_: Exception) { 0L } } catch (_: Exception) { 0L }
// Check if any virtual track from this CUE exists with matching modTime
val virtualPaths = existingCueVirtualPaths[uriStr] val virtualPaths = existingCueVirtualPaths[uriStr]
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
if (existingModified != null && existingModified == lastModified) { if (existingModified != null && existingModified == lastModified) {
// CUE is unchanged — mark virtual paths as current so they aren't removed
unchangedCueFiles.add(child to dir) unchangedCueFiles.add(child to dir)
for (vp in virtualPaths) { for (vp in virtualPaths) {
currentUris.add(vp) currentUris.add(vp)
} }
} else { } else {
// CUE is new or modified — needs scanning
cueFilesToScan.add(Triple(child, dir, lastModified)) cueFilesToScan.add(Triple(child, dir, lastModified))
} }
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
@@ -1458,7 +1444,6 @@ class MainActivity: FlutterFragmentActivity() {
existingModified ?: 0L existingModified ?: 0L
} }
// Check if file is new or modified
if (existingModified == null || existingModified != lastModified) { if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified)) audioFiles.add(Triple(child, path, lastModified))
} }
@@ -1475,7 +1460,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// Find removed files (in existing but not in current)
val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } val removedUris = existingFiles.keys.filter { !currentUris.contains(it) }
val totalFiles = currentUris.size val totalFiles = currentUris.size
val filesToProcess = audioFiles.size + cueFilesToScan.size val filesToProcess = audioFiles.size + cueFilesToScan.size
@@ -1503,7 +1487,6 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0 var scanned = 0
var errors = traversalErrors var errors = traversalErrors
// --- CUE first pass: parse new/modified CUE sheets ---
val cueReferencedAudioUris = mutableSetOf<String>() val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
@@ -1524,7 +1507,6 @@ class MainActivity: FlutterFragmentActivity() {
var tempCuePath: String? = null var tempCuePath: String? = null
var tempAudioPath: String? = null var tempAudioPath: String? = null
try { try {
// Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) { if (tempCuePath == null) {
errors++ errors++
@@ -1533,10 +1515,8 @@ class MainActivity: FlutterFragmentActivity() {
continue continue
} }
// Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath) val audioFileName = extractCueAudioFileName(tempCuePath)
// Find the referenced audio file as a sibling in the same SAF directory
val audioDoc = resolveCueAudioSibling( val audioDoc = resolveCueAudioSibling(
parentDir = parentDir, parentDir = parentDir,
cueName = cueName, cueName = cueName,
@@ -1551,10 +1531,8 @@ class MainActivity: FlutterFragmentActivity() {
continue continue
} }
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString()) cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1568,7 +1546,6 @@ class MainActivity: FlutterFragmentActivity() {
continue continue
} }
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName) val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath) val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1576,7 +1553,6 @@ class MainActivity: FlutterFragmentActivity() {
tempAudioPath = renamedAudio.absolutePath tempAudioPath = renamedAudio.absolutePath
} }
// Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary( val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath, tempCuePath,
tempDir, tempDir,
@@ -1588,7 +1564,6 @@ class MainActivity: FlutterFragmentActivity() {
for (j in 0 until cueArray.length()) { for (j in 0 until cueArray.length()) {
val trackObj = cueArray.getJSONObject(j) val trackObj = cueArray.getJSONObject(j)
results.put(trackObj) results.put(trackObj)
// Register each virtual path as current so deletion detection works
val virtualPath = trackObj.optString("filePath", "") val virtualPath = trackObj.optString("filePath", "")
if (virtualPath.isNotBlank()) { if (virtualPath.isNotBlank()) {
currentUris.add(virtualPath) currentUris.add(virtualPath)
@@ -1621,9 +1596,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// Discover audio siblings for unchanged CUE files so we skip them
// in the regular audio pass. Copy the .cue to temp (tiny file) to extract
// the audio filename, then find the sibling by name.
for ((cueDoc, parentDir) in unchangedCueFiles) { for ((cueDoc, parentDir) in unchangedCueFiles) {
var tempCue: String? = null var tempCue: String? = null
try { try {
@@ -1648,7 +1620,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _, lastModified) in audioFiles) { for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) { if (safScanCancel) {
updateSafScanProgress { it.isComplete = true } updateSafScanProgress { it.isComplete = true }
@@ -1661,7 +1632,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString() return result.toString()
} }
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) { if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++ scanned++
val processed = skippedCount + scanned val processed = skippedCount + scanned
@@ -1715,7 +1685,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// Recalculate removedUris now that CUE virtual paths have been registered
val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) }
updateSafScanProgress { updateSafScanProgress {
@@ -1893,7 +1862,6 @@ class MainActivity: FlutterFragmentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
setIntent(intent) setIntent(intent)
} }
@@ -2000,13 +1968,13 @@ class MainActivity: FlutterFragmentActivity() {
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.getDownloadProgress() Gobackend.getDownloadProgress()
} }
result.success(response) result.success(parseJsonPayload(response))
} }
"getAllDownloadProgress" -> { "getAllDownloadProgress" -> {
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress() Gobackend.getAllDownloadProgress()
} }
result.success(response) result.success(parseJsonPayload(response))
} }
"initItemProgress" -> { "initItemProgress" -> {
val itemId = call.argument<String>("item_id") ?: "" val itemId = call.argument<String>("item_id") ?: ""
@@ -2553,7 +2521,6 @@ class MainActivity: FlutterFragmentActivity() {
val tempPath = copyUriToTemp(uri) val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" ?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try { try {
// Replace file_path with temp path for Go
reqObj.put("file_path", tempPath) reqObj.put("file_path", tempPath)
val raw = Gobackend.reEnrichFile(reqObj.toString()) val raw = Gobackend.reEnrichFile(reqObj.toString())
val obj = JSONObject(raw) val obj = JSONObject(raw)
@@ -2631,7 +2598,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) result.success(null)
} }
// Deezer API methods
"searchDeezerAll" -> { "searchDeezerAll" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15 val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2642,7 +2608,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Tidal search API
"searchTidalAll" -> { "searchTidalAll" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15 val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2653,7 +2618,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Qobuz search API
"searchQobuzAll" -> { "searchQobuzAll" -> {
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15 val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2783,7 +2747,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Log methods
"getLogs" -> { "getLogs" -> {
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.getLogs() Gobackend.getLogs()
@@ -2816,7 +2779,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) result.success(null)
} }
// Extension System methods
"initExtensionSystem" -> { "initExtensionSystem" -> {
val extensionsDir = call.argument<String>("extensions_dir") ?: "" val extensionsDir = call.argument<String>("extensions_dir") ?: ""
val dataDir = call.argument<String>("data_dir") ?: "" val dataDir = call.argument<String>("data_dir") ?: ""
@@ -2961,7 +2923,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) result.success(null)
} }
// Extension Auth API methods
"getExtensionPendingAuth" -> { "getExtensionPendingAuth" -> {
val extensionId = call.argument<String>("extension_id") ?: "" val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
@@ -3011,7 +2972,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Extension FFmpeg API
"getPendingFFmpegCommand" -> { "getPendingFFmpegCommand" -> {
val commandId = call.argument<String>("command_id") ?: "" val commandId = call.argument<String>("command_id") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
@@ -3039,7 +2999,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Extension Custom Search API
"customSearchWithExtension" -> { "customSearchWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: "" val extensionId = call.argument<String>("extension_id") ?: ""
val query = call.argument<String>("query") ?: "" val query = call.argument<String>("query") ?: ""
@@ -3055,7 +3014,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Extension URL Handler API
"handleURLWithExtension" -> { "handleURLWithExtension" -> {
val url = call.argument<String>("url") ?: "" val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
@@ -3100,7 +3058,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Extension Post-Processing API
"runPostProcessing" -> { "runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata") ?: "" val metadataJson = call.argument<String>("metadata") ?: ""
@@ -3144,7 +3101,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Extension Store
"initExtensionStore" -> { "initExtensionStore" -> {
val cacheDir = call.argument<String>("cache_dir") ?: "" val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -3206,7 +3162,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) result.success(null)
} }
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> { "getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: "" val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
@@ -3221,7 +3176,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// Local Library Scanning
"setLibraryCoverCacheDir" -> { "setLibraryCoverCacheDir" -> {
val cacheDir = call.argument<String>("cache_dir") ?: "" val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -3298,7 +3252,7 @@ class MainActivity: FlutterFragmentActivity() {
Gobackend.getLibraryScanProgressJSON() Gobackend.getLibraryScanProgressJSON()
} }
} }
result.success(response) result.success(parseJsonPayload(response))
} }
"cancelLibraryScan" -> { "cancelLibraryScan" -> {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -3326,7 +3280,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
// CUE Sheet Parsing
"parseCueSheet" -> { "parseCueSheet" -> {
val cuePath = call.argument<String>("cue_path") ?: "" val cuePath = call.argument<String>("cue_path") ?: ""
val audioDir = call.argument<String>("audio_dir") ?: "" val audioDir = call.argument<String>("audio_dir") ?: ""
@@ -3338,17 +3291,14 @@ class MainActivity: FlutterFragmentActivity() {
?: return@withContext """{"error":"Failed to copy CUE file to temp"}""" ?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
var tempAudioPath: String? = null var tempAudioPath: String? = null
try { try {
// Extract audio filename from CUE text
val audioFileName = extractCueAudioFileName(tempCuePath) val audioFileName = extractCueAudioFileName(tempCuePath)
// Try to find the audio sibling in SAF
var audioDoc: DocumentFile? = null var audioDoc: DocumentFile? = null
val parentDir = safParentDir(uri) val parentDir = safParentDir(uri)
if (parentDir != null && !audioFileName.isNullOrBlank()) { if (parentDir != null && !audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
} }
// Fallback: try common extensions with the CUE base name
if (audioDoc == null && parentDir != null) { if (audioDoc == null && parentDir != null) {
val cueName = try { val cueName = try {
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: "" DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
@@ -3367,7 +3317,6 @@ class MainActivity: FlutterFragmentActivity() {
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
if (audioDoc != null) { if (audioDoc != null) {
// Copy audio to same temp dir with original name
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
@@ -3382,15 +3331,11 @@ class MainActivity: FlutterFragmentActivity() {
} }
} }
// Parse with audio in temp dir; Go will resolve there
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir) val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
// Replace the temp audio_path with the SAF content:// URI
// so Dart knows it's a SAF file and handles it accordingly
if (audioDoc != null) { if (audioDoc != null) {
val resultObj = JSONObject(resultJson) val resultObj = JSONObject(resultJson)
resultObj.put("audio_path", audioDoc.uri.toString()) resultObj.put("audio_path", audioDoc.uri.toString())
// Also pass the original CUE URI for reference
resultObj.put("cue_path", cuePath) resultObj.put("cue_path", cuePath)
resultObj.toString() resultObj.toString()
} else { } else {
+7 -1
View File
@@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
func isLyricsDescription(description string) bool { func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) { switch strings.ToLower(strings.TrimSpace(description)) {
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc": case
"lyrics",
"lyric",
"unsyncedlyrics",
"unsynced lyrics",
"uslt",
"lrc":
return true return true
default: default:
return false return false
+133
View File
@@ -0,0 +1,133 @@
package gobackend
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func ffmpegCommand(args ...string) *exec.Cmd {
if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil {
return exec.Command(ffmpegPath, args...)
}
return exec.Command("ffmpeg", args...)
}
func runFFmpegTestCommand(t *testing.T, args ...string) {
t.Helper()
cmd := ffmpegCommand(args...)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("ffmpeg failed: %v\n%s", err, string(output))
}
}
func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not available")
}
tempDir := t.TempDir()
sourceFlac := filepath.Join(tempDir, "source.flac")
baseMp3 := filepath.Join(tempDir, "base.mp3")
finalMp3 := filepath.Join(tempDir, "final.mp3")
coverPath := filepath.Join(tempDir, "cover.jpg")
lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics"
runFFmpegTestCommand(
t,
"-y",
"-f",
"lavfi",
"-i",
"sine=frequency=440:duration=1",
"-c:a",
"flac",
sourceFlac,
)
runFFmpegTestCommand(
t,
"-y",
"-f",
"lavfi",
"-i",
"color=c=red:s=32x32:d=1",
"-frames:v",
"1",
coverPath,
)
runFFmpegTestCommand(
t,
"-y",
"-i",
sourceFlac,
"-b:a",
"320k",
"-metadata",
"title=Test Song",
"-metadata",
"artist=Test Artist",
"-metadata",
"lyrics="+lyrics,
baseMp3,
)
runFFmpegTestCommand(
t,
"-y",
"-i",
baseMp3,
"-i",
coverPath,
"-map",
"0:a",
"-map_metadata",
"-1",
"-map",
"1:0",
"-c:v:0",
"copy",
"-id3v2_version",
"3",
"-metadata",
"title=Test Song",
"-metadata",
"artist=Test Artist",
"-metadata",
"lyrics="+lyrics,
"-metadata:s:v",
"title=Album cover",
"-metadata:s:v",
"comment=Cover (front)",
"-c:a",
"copy",
finalMp3,
)
meta, err := ReadID3Tags(finalMp3)
if err != nil {
t.Fatalf("ReadID3Tags failed: %v", err)
}
if meta == nil {
t.Fatalf("ReadID3Tags returned nil metadata")
}
embeddedLyrics, err := ExtractLyrics(finalMp3)
if err != nil {
t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta)
}
if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") {
t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta)
}
if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") {
t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta)
}
if _, err := os.Stat(finalMp3); err != nil {
t.Fatalf("expected final mp3 to exist: %v", err)
}
}
+34 -4
View File
@@ -17,6 +17,8 @@ const (
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 // Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`)
func convertSmallToMedium(imageURL string) string { func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) { if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
maxURL := upgradeToMaxQuality(downloadURL) maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL { if maxURL != downloadURL {
downloadURL = maxURL downloadURL = maxURL
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") { if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)") GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
} }
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
} }
func upgradeToMaxQuality(coverURL string) string { func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) { if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
} }
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") { if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL) return upgradeDeezerCover(coverURL)
} }
if strings.Contains(coverURL, "resources.tidal.com") {
return upgradeTidalCover(coverURL)
}
if strings.Contains(coverURL, "static.qobuz.com") {
return upgradeQobuzCover(coverURL)
}
return coverURL return coverURL
} }
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
return upgraded return upgraded
} }
func upgradeTidalCover(coverURL string) string {
if !strings.Contains(coverURL, "resources.tidal.com") {
return coverURL
}
upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg")
if upgraded != coverURL {
GoLog("[Cover] Tidal: upgraded to origin resolution")
}
return upgraded
}
func upgradeQobuzCover(coverURL string) string {
if !strings.Contains(coverURL, "static.qobuz.com") {
return coverURL
}
upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg")
if upgraded != coverURL {
GoLog("[Cover] Qobuz: upgraded to max resolution")
}
return upgraded
}
func GetCoverFromSpotify(imageURL string, maxQuality bool) string { func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
if imageURL == "" { if imageURL == "" {
return "" return ""
} }
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL) result := convertSmallToMedium(imageURL)
if maxQuality { if maxQuality {
-10
View File
@@ -13,7 +13,6 @@ import (
// CueSheet represents a parsed .cue file // CueSheet represents a parsed .cue file
type CueSheet struct { type CueSheet struct {
// Album-level metadata
Performer string `json:"performer"` Performer string `json:"performer"`
Title string `json:"title"` Title string `json:"title"`
FileName string `json:"file_name"` FileName string `json:"file_name"`
@@ -32,7 +31,6 @@ type CueTrack struct {
Performer string `json:"performer"` Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"` ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"` Composer string `json:"composer,omitempty"`
// Index positions in seconds (fractional)
StartTime float64 `json:"start_time"` // INDEX 01 in seconds StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present) PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
} }
@@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue continue
} }
// Handle BOM at start of file
if strings.HasPrefix(line, "\xef\xbb\xbf") { if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf") line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
upper := strings.ToUpper(line) upper := strings.ToUpper(line)
// REM commands (album-level metadata)
if strings.HasPrefix(upper, "REM ") { if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line) matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 { if len(matches) == 3 {
@@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
if strings.HasPrefix(upper, "FILE ") { if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):] rest := line[len("FILE "):]
// Extract filename and type
// Format: FILE "filename.flac" WAVE
// or: FILE filename.flac WAVE
fname, ftype := parseCueFileLine(rest) fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname sheet.FileName = fname
sheet.FileType = ftype sheet.FileType = ftype
@@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
} }
if strings.HasPrefix(upper, "TRACK ") { if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil { if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack) sheet.Tracks = append(sheet.Tracks, *currentTrack)
} }
@@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue continue
} }
// SONGWRITER (used as composer sometimes)
if strings.HasPrefix(upper, "SONGWRITER ") { if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):]) value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil { if currentTrack != nil {
@@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
} }
} }
// Don't forget the last track
if currentTrack != nil { if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack) sheet.Tracks = append(sheet.Tracks, *currentTrack)
} }
+1 -2
View File
@@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
for attempt := 0; attempt <= deezerMaxRetries; attempt++ { for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 { if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff delay := deezerRetryDelay * time.Duration(1<<(attempt-1))
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay) GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay) time.Sleep(delay)
} }
@@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
lastErr = err lastErr = err
errStr := err.Error() errStr := err.Error()
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") || isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "connection refused") ||
-1
View File
@@ -319,7 +319,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err
return "", fmt.Errorf("MusicDL error: %s", errMsg) return "", fmt.Errorf("MusicDL error: %s", errMsg)
} }
// Try various response fields for download URL
for _, key := range []string{"download_url", "url", "link"} { for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" { if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil return strings.TrimSpace(urlVal), nil
+58 -126
View File
@@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
} }
// SetSongLinkNetworkOptions is kept for backward compatibility. // SetSongLinkNetworkOptions is kept for backward compatibility.
// It now applies global network compatibility options for all backend API requests.
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) { func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
} }
@@ -407,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) {
} }
} }
err = deezerErr err = deezerErr
case "youtube":
youtubeResult, youtubeErr := downloadFromYouTube(req)
if youtubeErr == nil {
result = DownloadResult{
FilePath: youtubeResult.FilePath,
BitDepth: 0,
SampleRate: 0,
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
}
err = youtubeErr
default: default:
return errorResponse("Unknown service: " + req.Service) return errorResponse("Unknown service: " + req.Service)
} }
@@ -476,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
serviceNormalized := strings.ToLower(serviceRaw) serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req normalizedReq := req
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) { if isBuiltInProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized normalizedReq.Service = serviceNormalized
} }
@@ -486,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
} }
normalizedJSON := string(normalizedBytes) normalizedJSON := string(normalizedBytes)
if serviceNormalized == "youtube" {
return DownloadFromYouTube(normalizedJSON)
}
if req.UseExtensions { if req.UseExtensions {
// Respect strict mode when auto fallback is disabled: // Respect strict mode when auto fallback is disabled:
// for built-in providers, route directly to selected service only. // for built-in providers, route directly to selected service only.
@@ -721,29 +698,57 @@ func ReadFileMetadata(filePath string) (string, error) {
if isFlac { if isFlac {
metadata, err := ReadMetadata(filePath) metadata, err := ReadMetadata(filePath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err) // File may have wrong extension (e.g. opus saved as .flac).
} // Try Ogg/Opus parser as fallback before giving up.
result["title"] = metadata.Title GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
result["artist"] = metadata.Artist oggMeta, oggErr := ReadOggVorbisComments(filePath)
result["album"] = metadata.Album if oggErr == nil && oggMeta != nil {
result["album_artist"] = metadata.AlbumArtist result["title"] = oggMeta.Title
result["date"] = metadata.Date result["artist"] = oggMeta.Artist
result["track_number"] = metadata.TrackNumber result["album"] = oggMeta.Album
result["disc_number"] = metadata.DiscNumber result["album_artist"] = oggMeta.AlbumArtist
result["isrc"] = metadata.ISRC result["date"] = oggMeta.Date
result["lyrics"] = metadata.Lyrics if oggMeta.Date == "" {
result["genre"] = metadata.Genre result["date"] = oggMeta.Year
result["label"] = metadata.Label }
result["copyright"] = metadata.Copyright result["track_number"] = oggMeta.TrackNumber
result["composer"] = metadata.Composer result["disc_number"] = oggMeta.DiscNumber
result["comment"] = metadata.Comment result["isrc"] = oggMeta.ISRC
result["lyrics"] = oggMeta.Lyrics
result["genre"] = oggMeta.Genre
result["composer"] = oggMeta.Composer
result["comment"] = oggMeta.Comment
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
} else {
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
quality, qualityErr := GetAudioQuality(filePath) quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil { if qualityErr == nil {
result["bit_depth"] = quality.BitDepth result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 { if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
} }
} }
} else if isM4A { } else if isM4A {
@@ -910,7 +915,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{ resp := map[string]any{
"success": true, "success": true,
"method": "ffmpeg", "method": "ffmpeg",
@@ -1670,62 +1674,6 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func DownloadFromYouTube(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
applySongLinkRegionFromRequest(&req)
defer closeOwnedOutputFD(req.OutputFD)
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
req.OutputPath = strings.TrimSpace(req.OutputPath)
req.OutputExt = strings.TrimSpace(req.OutputExt)
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
youtubeResult, err := downloadFromYouTube(req)
if err != nil {
return errorResponse(err.Error())
}
resp := DownloadResponse{
Success: true,
Message: "Downloaded from YouTube",
FilePath: youtubeResult.FilePath,
Service: "youtube",
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
CoverURL: req.CoverURL,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
func IsYouTubeURLExport(urlStr string) bool {
return IsYouTubeURL(urlStr)
}
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
return ExtractYouTubeVideoID(urlStr)
}
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" { if coverURL == "" {
return fmt.Errorf("no cover URL provided") return fmt.Errorf("no cover URL provided")
@@ -1958,7 +1906,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
} }
} }
// Log metadata summary before embedding
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n", GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist) req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n", GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
@@ -2041,7 +1988,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
} }
} }
// Build enriched metadata response for Dart (includes online search results)
enrichedMeta := map[string]interface{}{ enrichedMeta := map[string]interface{}{
"track_name": req.TrackName, "track_name": req.TrackName,
"artist_name": req.ArtistName, "artist_name": req.ArtistName,
@@ -2187,12 +2133,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err return "", err
} }
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
result := map[string]interface{}{ result := map[string]interface{}{
"id": ext.ID, "id": ext.ID,
"name": ext.Manifest.Name, "name": ext.Manifest.Name,
@@ -2226,12 +2166,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err return "", err
} }
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
result := map[string]interface{}{ result := map[string]interface{}{
"id": ext.ID, "id": ext.ID,
"display_name": ext.Manifest.DisplayName, "display_name": ext.Manifest.DisplayName,
@@ -3226,11 +3160,7 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return "", fmt.Errorf("extension store not initialized") return "", fmt.Errorf("extension store not initialized")
} }
if forceRefresh { extensions, err := store.getExtensionsWithStatus(forceRefresh)
store.FetchRegistry(true)
}
extensions, err := store.GetExtensionsWithStatus()
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -3324,12 +3254,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
if !ext.Enabled { if !ext.Enabled {
return "", fmt.Errorf("extension '%s' is disabled", extensionID) return "", fmt.Errorf("extension '%s' is disabled", extensionID)
} }
vm, err := ext.lockReadyVM()
if err != nil {
return "", err
}
defer ext.VMMu.Unlock()
// Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu // Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist). // to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
@@ -3339,7 +3271,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
})() })()
`, functionName, functionName) `, functionName, functionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout) result, err := RunWithTimeoutAndRecover(vm, script, timeout)
if err != nil { if err != nil {
return "", fmt.Errorf("%s failed: %w", functionName, err) return "", fmt.Errorf("%s failed: %w", functionName, err)
} }
+241 -114
View File
@@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int {
} }
type LoadedExtension struct { type LoadedExtension struct {
ID string `json:"id"` ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"` Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"` VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"` VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime runtime *ExtensionRuntime
Enabled bool `json:"enabled"` initialized bool
Error string `json:"error,omitempty"` Enabled bool `json:"enabled"`
DataDir string `json:"data_dir"` Error string `json:"error,omitempty"`
SourceDir string `json:"source_dir"` DataDir string `json:"data_dir"`
IconPath string `json:"icon_path"` SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
}
func getExtensionInitSettings(extensionID string) map[string]interface{} {
settings := GetExtensionSettingsStore().GetAll(extensionID)
if len(settings) == 0 {
return settings
}
filtered := make(map[string]interface{}, len(settings))
for key, value := range settings {
if strings.HasPrefix(key, "_") {
continue
}
filtered[key] = value
}
return filtered
}
func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error {
if ext.VM == nil || ext.runtime == nil {
if err := initializeVMLocked(ext); err != nil {
ext.Error = err.Error()
ext.Enabled = false
return err
}
}
if applyStoredSettings && !ext.initialized {
settings := getExtensionInitSettings(ext.ID)
if len(settings) > 0 {
if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil {
teardownVMLocked(ext)
ext.Error = err.Error()
ext.Enabled = false
return err
}
} else {
ext.initialized = true
}
}
ext.Error = ""
return nil
}
func (ext *LoadedExtension) ensureRuntimeReady() error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return ensureRuntimeReadyLocked(ext, true)
}
func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) {
ext.VMMu.Lock()
if err := ensureRuntimeReadyLocked(ext, true); err != nil {
ext.VMMu.Unlock()
return nil, err
}
return ext.VM, nil
} }
type ExtensionManager struct { type ExtensionManager struct {
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
SourceDir: extDir, SourceDir: extDir,
} }
if err := m.initializeVM(ext); err != nil { if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
ext.Enabled = false ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
} }
m.extensions[manifest.Name] = ext m.extensions[manifest.Name] = ext
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
return ext, nil return ext, nil
} }
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { func initializeVMLocked(ext *LoadedExtension) error {
ext.VM = nil
ext.runtime = nil
ext.initialized = false
vm := goja.New() vm := goja.New()
ext.VM = vm ext.VM = vm
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
return nil return nil
} }
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
return initializeVMLocked(ext)
}
func initializeExtensionWithSettingsLocked(
ext *LoadedExtension,
settings map[string]interface{},
) error {
if ext.VM == nil {
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("Failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := ext.VM.RunString(script)
if err != nil {
ext.Error = fmt.Sprintf("initialize failed: %v", err)
ext.Enabled = false
GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err)
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
ext.Error = errMsg
ext.Enabled = false
GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
ext.initialized = true
GoLog("[Extension] Initialized %s\n", ext.ID)
return nil
}
func runCleanupLocked(ext *LoadedExtension) error {
if ext.VM != nil {
script := `
(function() {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := ext.VM.RunString(script)
if err != nil {
return err
}
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) {
GoLog("[Extension] Cleanup called for %s\n", ext.ID)
}
}
return nil
}
func teardownVMLocked(ext *LoadedExtension) {
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err)
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err)
}
ext.runtime.closeStorageFlusher()
}
ext.runtime = nil
ext.VM = nil
ext.initialized = false
}
func validateExtensionLoad(ext *LoadedExtension) error {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
if err := initializeVMLocked(ext); err != nil {
return err
}
teardownVMLocked(ext)
return nil
}
func (m *ExtensionManager) UnloadExtension(extensionID string) error { func (m *ExtensionManager) UnloadExtension(extensionID string) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return fmt.Errorf("Extension not found") return fmt.Errorf("Extension not found")
} }
if ext.VM != nil { ext.VMMu.Lock()
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null") teardownVMLocked(ext)
if err != nil { ext.VMMu.Unlock()
GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err)
} else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) {
GoLog("[Extension] Cleanup called for %s\n", extensionID)
}
}
if ext.runtime != nil {
if err := ext.runtime.flushStorageNow(); err != nil {
GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err)
}
ext.runtime.closeStorageFlusher()
ext.runtime = nil
}
delete(m.extensions, extensionID) delete(m.extensions, extensionID)
GoLog("[Extension] Unloaded extension: %s\n", extensionID) GoLog("[Extension] Unloaded extension: %s\n", extensionID)
@@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool)
return fmt.Errorf("Extension not found") return fmt.Errorf("Extension not found")
} }
ext.Enabled = enabled if enabled {
ext.Enabled = true
if err := ext.ensureRuntimeReady(); err != nil {
store := GetExtensionSettingsStore()
ext.Enabled = false
_ = store.Set(extensionID, "_enabled", false)
return err
}
} else {
ext.Enabled = false
ext.Error = ""
ext.VMMu.Lock()
teardownVMLocked(ext)
ext.VMMu.Unlock()
}
GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled]) GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled])
store := GetExtensionSettingsStore() store := GetExtensionSettingsStore()
@@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
} }
} }
if err := m.initializeVM(ext); err != nil { if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
ext.Enabled = false ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err)
} }
m.extensions[manifest.Name] = ext m.extensions[manifest.Name] = ext
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
SourceDir: extDir, SourceDir: extDir,
} }
if err := m.initializeVM(ext); err != nil { if wasEnabled {
if err := ext.ensureRuntimeReady(); err != nil {
GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err)
}
} else if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error() ext.Error = err.Error()
ext.Enabled = false ext.Enabled = false
GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err) GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err)
} }
m.mu.Lock() m.mu.Lock()
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension not found") return fmt.Errorf("Extension not found")
} }
if ext.VM == nil { ext.VMMu.Lock()
return fmt.Errorf("Extension failed to load. Please reinstall the extension") defer ext.VMMu.Unlock()
}
settingsJSON, err := json.Marshal(settings) if err := ensureRuntimeReadyLocked(ext, false); err != nil {
if err != nil {
return fmt.Errorf("Failed to save settings")
}
script := fmt.Sprintf(`
(function() {
var settings = %s;
if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') {
try {
extension.initialize(settings);
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no initialize function' };
})()
`, string(settingsJSON))
result, err := ext.VM.RunString(script)
if err != nil {
ext.Error = fmt.Sprintf("initialize failed: %v", err)
ext.Enabled = false
GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err)
return err return err
} }
return initializeExtensionWithSettingsLocked(ext, settings)
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
ext.Error = errMsg
ext.Enabled = false
GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("initialize failed: %s", errMsg)
}
}
}
GoLog("[Extension] Initialized %s\n", extensionID)
return nil
} }
func (m *ExtensionManager) CleanupExtension(extensionID string) error { func (m *ExtensionManager) CleanupExtension(extensionID string) error {
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
if ext.VM == nil { if ext.VM == nil {
return nil return nil
} }
ext.VMMu.Lock()
script := ` defer ext.VMMu.Unlock()
(function() { if err := runCleanupLocked(ext); err != nil {
if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') {
try {
extension.cleanup();
return { success: true };
} catch (e) {
return { success: false, error: e.toString() };
}
}
return { success: true, message: 'no cleanup function' };
})()
`
result, err := ext.VM.RunString(script)
if err != nil {
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err) GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err)
return err return err
} }
if result != nil && !goja.IsUndefined(result) {
exported := result.Export()
if resultMap, ok := exported.(map[string]interface{}); ok {
if success, ok := resultMap["success"].(bool); ok && !success {
errMsg := "unknown error"
if e, ok := resultMap["error"].(string); ok {
errMsg = e
}
GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg)
return fmt.Errorf("cleanup failed: %s", errMsg)
}
}
}
GoLog("[Extension] Cleaned up %s\n", extensionID) GoLog("[Extension] Cleaned up %s\n", extensionID)
return nil return nil
} }
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension not found: %s", extensionID) return nil, fmt.Errorf("extension not found: %s", extensionID)
} }
if ext.VM == nil { if err := ext.ensureRuntimeReady(); err != nil {
return nil, fmt.Errorf("extension VM not initialized") return nil, err
} }
if !ext.Enabled { if !ext.Enabled {
+56 -28
View File
@@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper
} }
} }
func (p *ExtensionProviderWrapper) lockReadyVM() error {
vm, err := p.extension.lockReadyVM()
if err != nil {
return err
}
p.vm = vm
return nil
}
func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) {
if !p.extension.Manifest.IsMetadataProvider() { if !p.extension.Manifest.IsMetadataProvider() {
return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID)
@@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
if !p.extension.Enabled { if !p.extension.Enabled {
return track, nil return track, nil
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
return track, nil
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
trackJSON, err := json.Marshal(track) trackJSON, err := json.Marshal(track)
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -501,8 +518,13 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return &ExtDownloadResult{
Success: false,
ErrorMessage: err.Error(),
ErrorType: "init_error",
}, nil
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value {
@@ -1626,8 +1648,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
if options == nil { if options == nil {
@@ -1707,8 +1730,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(` script := fmt.Sprintf(`
@@ -1792,8 +1816,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack) sourceJSON, _ := json.Marshal(sourceTrack)
@@ -1862,8 +1887,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return &PostProcessResult{Success: false, Error: err.Error()}, nil
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata) metadataJSON, _ := json.Marshal(metadata)
@@ -1924,8 +1950,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return &PostProcessResult{Success: false, Error: err.Error()}, nil
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata) metadataJSON, _ := json.Marshal(metadata)
@@ -2182,8 +2209,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
if !p.extension.Enabled { if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
} }
if err := p.lockReadyVM(); err != nil {
p.extension.VMMu.Lock() return nil, err
}
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
// Use global variables to avoid JS injection issues with special characters in track/artist names // Use global variables to avoid JS injection issues with special characters in track/artist names
+17 -13
View File
@@ -97,8 +97,8 @@ type StoreExtensionResponse struct {
HasUpdate bool `json:"has_update"` HasUpdate bool `json:"has_update"`
} }
func (e *StoreExtension) ToResponse() StoreExtensionResponse { func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
return StoreExtensionResponse{ return &StoreExtensionResponse{
ID: e.ID, ID: e.ID,
Name: e.Name, Name: e.Name,
DisplayName: e.getDisplayName(), DisplayName: e.getDisplayName(),
@@ -236,7 +236,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
s.cacheMu.Lock() s.cacheMu.Lock()
defer s.cacheMu.Unlock() defer s.cacheMu.Unlock()
// Check if a registry URL has been configured
if s.registryURL == "" { if s.registryURL == "" {
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first") return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
} }
@@ -289,8 +288,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil return &registry, nil
} }
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false) registry, err := s.FetchRegistry(forceRefresh)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -304,22 +303,29 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
} }
} }
result := make([]StoreExtensionResponse, len(registry.Extensions)) LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
for i, ext := range registry.Extensions {
resp := ext.ToResponse()
result := make([]*StoreExtensionResponse, 0, len(registry.Extensions))
for i := range registry.Extensions {
ext := &registry.Extensions[i]
resp := ext.ToResponse()
if installedVersion, ok := installed[ext.ID]; ok { if installedVersion, ok := installed[ext.ID]; ok {
resp.IsInstalled = true resp.IsInstalled = true
resp.InstalledVersion = installedVersion resp.InstalledVersion = installedVersion
resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0
} }
result[i] = resp result = append(result, resp)
} }
LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result))
return result, nil return result, nil
} }
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) {
return s.getExtensionsWithStatus(false)
}
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false) registry, err := s.FetchRegistry(false)
if err != nil { if err != nil {
@@ -389,7 +395,6 @@ func ResolveRegistryURL(input string) (string, error) {
return input, nil return input, nil
} }
// Try to match https://github.com/<owner>/<repo>[/...]
const ghPrefix = "https://github.com/" const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) { if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently. // Also accept http:// and upgrade silently.
@@ -470,7 +475,7 @@ func (s *ExtensionStore) GetCategories() []string {
} }
} }
func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) {
extensions, err := s.GetExtensionsWithStatus() extensions, err := s.GetExtensionsWithStatus()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -480,7 +485,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return extensions, nil return extensions, nil
} }
var result []StoreExtensionResponse result := make([]*StoreExtensionResponse, 0, len(extensions))
queryLower := toLower(query) queryLower := toLower(query)
for _, ext := range extensions { for _, ext := range extensions {
@@ -493,7 +498,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
!containsIgnoreCase(ext.DisplayName, queryLower) && !containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) && !containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) { !containsIgnoreCase(ext.Author, queryLower) {
// Check tags
found := false found := false
for _, tag := range ext.Tags { for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) { if containsIgnoreCase(tag, queryLower) {
+1 -1
View File
@@ -12,6 +12,7 @@ require (
github.com/refraction-networking/utls v1.8.2 github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
golang.org/x/net v0.50.0 golang.org/x/net v0.50.0
golang.org/x/text v0.34.0
) )
require ( require (
@@ -24,6 +25,5 @@ require (
golang.org/x/mod v0.33.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect golang.org/x/tools v0.42.0 // indirect
) )
-18
View File
@@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw=
github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
@@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4=
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
-5
View File
@@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf
continue continue
} }
// Check for ISP blocking via HTTP status codes
// Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 { if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
bodyStr := strings.ToLower(string(body)) bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{ ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your", "blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by", "restricted", "censored", "unavailable for legal", "blocked by",
@@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil return nil
} }
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL) ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil { if ispErr != nil {
@@ -553,7 +549,6 @@ func extractDomain(rawURL string) string {
return "unknown" return "unknown"
} }
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil { if err == nil {
return nil return nil
-2
View File
@@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
resp, err := sharedClient.Do(req) resp, err := sharedClient.Do(req)
if err == nil { if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
if resp.StatusCode == 403 || resp.StatusCode == 503 { if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body) body, readErr := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
@@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil return resp, nil
} }
// Check if error might be TLS-related (Cloudflare blocking)
errStr := strings.ToLower(err.Error()) errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") || tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") || strings.Contains(errStr, "handshake") ||
+1 -15
View File
@@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
continue continue
} }
// Skip audio files that are referenced by a .cue sheet
// (they will be represented by the cue sheet's track entries instead)
if cueReferencedAudioFiles[filePath] { if cueReferencedAudioFiles[filePath] {
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath)) GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue continue
@@ -557,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) { func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64) existingFiles := make(map[string]int64)
if snapshotPath == "" { if snapshotPath == "" {
@@ -637,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
libraryScanProgress.TotalFiles = totalFiles libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock() libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo var filesToScan []libraryAudioFileInfo
skippedCount := 0 skippedCount := 0
existingCueTrackModTimes := make(map[string]int64) existingCueTrackModTimes := make(map[string]int64)
@@ -653,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
for _, f := range currentFiles { for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path] existingModTime, exists := existingFiles[f.path]
if !exists { if !exists {
// For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" { if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks { if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
if f.modTime == cueTrackModTime { if f.modTime == cueTrackModTime {
skippedCount++ skippedCount++
} else { } else {
@@ -675,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
var deletedPaths []string var deletedPaths []string
for existingPath := range existingFiles { for existingPath := range existingFiles {
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
// check if the base .cue file still exists on disk
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 { if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx] baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] { if currentPathSet[baseCuePath] {
continue // Base .cue file still exists, not deleted continue
} }
// Base CUE file is gone, mark virtual path as deleted
deletedPaths = append(deletedPaths, existingPath) deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] { } else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath) deletedPaths = append(deletedPaths, existingPath)
@@ -713,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
scanTime := time.Now().UTC().Format(time.RFC3339) scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0 errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
cueReferencedAudioFilesInc := make(map[string]bool) cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo) parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan { for _, f := range filesToScan {
@@ -748,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
ext := strings.ToLower(filepath.Ext(f.path)) ext := strings.ToLower(filepath.Ext(f.path))
// Handle .cue files: produce multiple track results
if ext == ".cue" { if ext == ".cue" {
var cueResults []LibraryScanResult var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path] cueInfo, ok := parsedCueFiles[f.path]
@@ -773,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
continue continue
} }
// Skip audio files referenced by .cue sheets
if cueReferencedAudioFilesInc[f.path] { if cueReferencedAudioFilesInc[f.path] {
continue continue
} }
-6
View File
@@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
return return
} }
// Validate provider names
validNames := map[string]bool{ validNames := map[string]bool{
LyricsProviderSpotifyAPI: true, LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true, LyricsProviderLRCLIB: true,
@@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
GoLog("[Lyrics] Provider order set to: %v\n", valid) GoLog("[Lyrics] Provider order set to: %v\n", valid)
} }
// GetLyricsProviderOrder returns the current lyrics provider order.
func GetLyricsProviderOrder() []string { func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock() lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock() defer lyricsProvidersMu.RUnlock()
@@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string {
return result return result
} }
// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} { func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{ return []map[string]interface{}{
{"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"}, {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"},
@@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions {
return opts return opts
} }
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
func SetLyricsFetchOptions(opts LyricsFetchOptions) { func SetLyricsFetchOptions(opts LyricsFetchOptions) {
normalized := normalizeLyricsFetchOptions(opts) normalized := normalizeLyricsFetchOptions(opts)
@@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
) )
} }
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
func GetLyricsFetchOptions() LyricsFetchOptions { func GetLyricsFetchOptions() LyricsFetchOptions {
lyricsFetchOptionsMu.RLock() lyricsFetchOptionsMu.RLock()
defer lyricsFetchOptionsMu.RUnlock() defer lyricsFetchOptionsMu.RUnlock()
@@ -667,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st
GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder) GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder)
// Cascade through all configured built-in providers
for _, providerName := range providerOrder { for _, providerName := range providerOrder {
GoLog("[Lyrics] Trying provider: %s\n", providerName) GoLog("[Lyrics] Trying provider: %s\n", providerName)
+24 -5
View File
@@ -262,26 +262,35 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string {
return fmt.Sprintf("%s (%s)", title, version) return fmt.Sprintf("%s (%s)", title, version)
} }
var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`)
func qobuzUpscaleImageURL(url string) string {
if url == "" {
return ""
}
return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg")
}
func qobuzTrackAlbumImage(track *QobuzTrack) string { func qobuzTrackAlbumImage(track *QobuzTrack) string {
if track == nil { if track == nil {
return "" return ""
} }
return qobuzFirstNonEmpty( return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
track.Album.Image.Large, track.Album.Image.Large,
track.Album.Image.Small, track.Album.Image.Small,
track.Album.Image.Thumbnail, track.Album.Image.Thumbnail,
) ))
} }
func qobuzAlbumImage(album *qobuzAlbumDetails) string { func qobuzAlbumImage(album *qobuzAlbumDetails) string {
if album == nil { if album == nil {
return "" return ""
} }
return qobuzFirstNonEmpty( return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
album.Image.Large, album.Image.Large,
album.Image.Small, album.Image.Small,
album.Image.Thumbnail, album.Image.Thumbnail,
) ))
} }
func qobuzTrackArtistID(track *QobuzTrack) string { func qobuzTrackArtistID(track *QobuzTrack) string {
@@ -936,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items))
for i := range album.Tracks.Items { for i := range album.Tracks.Items {
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i])) track := &album.Tracks.Items[i]
track.Album.ID = album.ID
track.Album.Title = album.Title
track.Album.ReleaseDate = album.ReleaseDateOriginal
track.Album.Image = qobuzImageSet{
Thumbnail: album.Image.Thumbnail,
Small: album.Image.Small,
Large: album.Image.Large,
}
track.Album.TracksCount = album.TracksCount
tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track))
} }
return &AlbumResponsePayload{ return &AlbumResponsePayload{
+5 -7
View File
@@ -1015,13 +1015,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
for _, item := range itemsModule.PagedList.Items { for _, item := range itemsModule.PagedList.Items {
track := item.Item track := item.Item
if track.Album.ID == 0 { track.Album.ID = headerModule.Album.ID
track.Album.ID = headerModule.Album.ID track.Album.Title = headerModule.Album.Title
track.Album.Title = headerModule.Album.Title track.Album.Cover = headerModule.Album.Cover
track.Album.Cover = headerModule.Album.Cover track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.ReleaseDate = headerModule.Album.ReleaseDate track.Album.URL = headerModule.Album.URL
track.Album.URL = headerModule.Album.URL
}
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
} }
+1 -6
View File
@@ -24,11 +24,9 @@ func normalizeLooseTitle(title string) string {
b.WriteRune(r) b.WriteRune(r)
case unicode.IsSpace(r): case unicode.IsSpace(r):
b.WriteByte(' ') b.WriteByte(' ')
// Treat common separators as spaces.
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ') b.WriteByte(' ')
default: default:
// Drop other punctuation/symbols (including emoji) for loose matching.
} }
} }
@@ -59,7 +57,6 @@ func normalizeLooseArtistName(name string) string {
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ') b.WriteByte(' ')
default: default:
// Drop remaining punctuation/symbols for loose artist matching.
} }
} }
@@ -102,13 +99,11 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String() return b.String()
} }
// ==================== Shared Track Verification ====================
// resolvedTrackInfo holds the metadata fetched from a provider for verification. // resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct { type resolvedTrackInfo struct {
Title string Title string
ArtistName string ArtistName string
Duration int // seconds Duration int
} }
// trackMatchesRequest checks whether a resolved track from a provider matches // trackMatchesRequest checks whether a resolved track from a provider matches
-750
View File
@@ -1,750 +0,0 @@
package gobackend
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
)
type YouTubeDownloader struct {
client *http.Client
apiURL string
mu sync.Mutex
}
const spotubeBaseURL = "https://spotubedl.com"
var (
globalYouTubeDownloader *YouTubeDownloader
youtubeDownloaderOnce sync.Once
)
type YouTubeQuality string
const (
YouTubeQualityOpus320 YouTubeQuality = "opus_320"
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityOpus128 YouTubeQuality = "opus_128"
YouTubeQualityMP3128 YouTubeQuality = "mp3_128"
YouTubeQualityMP3256 YouTubeQuality = "mp3_256"
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
)
var (
youtubeOpusSupportedBitrates = []int{128, 256, 320}
youtubeMp3SupportedBitrates = []int{128, 256, 320}
)
type CobaltRequest struct {
URL string `json:"url"`
AudioBitrate string `json:"audioBitrate,omitempty"`
AudioFormat string `json:"audioFormat,omitempty"`
DownloadMode string `json:"downloadMode,omitempty"`
FilenameStyle string `json:"filenameStyle,omitempty"`
DisableMetadata bool `json:"disableMetadata,omitempty"`
}
type CobaltResponse struct {
Status string `json:"status"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
Error *struct {
Code string `json:"code"`
Context *struct {
Service string `json:"service,omitempty"`
Limit int `json:"limit,omitempty"`
} `json:"context,omitempty"`
} `json:"error,omitempty"`
}
type YouTubeDownloadResult struct {
FilePath string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
Format string // "opus" or "mp3"
Bitrate int
LyricsLRC string
CoverData []byte
}
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(DownloadTimeout),
apiURL: "https://api.qwkuns.me",
}
})
return globalYouTubeDownloader
}
func extractBitrateFromQuality(raw string, defaultBitrate int) int {
parts := strings.FieldsFunc(raw, func(r rune) bool {
return (r < '0' || r > '9')
})
for i := len(parts) - 1; i >= 0; i-- {
part := parts[i]
if part == "" {
continue
}
if parsed, err := strconv.Atoi(part); err == nil {
return parsed
}
}
return defaultBitrate
}
func nearestSupportedBitrate(value int, supported []int) int {
nearest := supported[0]
nearestDistance := absInt(value - nearest)
for _, option := range supported[1:] {
distance := absInt(value - option)
// On tie prefer higher quality.
if distance < nearestDistance || (distance == nearestDistance && option > nearest) {
nearest = option
nearestDistance = distance
}
}
return nearest
}
func absInt(value int) int {
if value < 0 {
return -value
}
return value
}
func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) {
normalizedRaw := strings.ToLower(strings.TrimSpace(raw))
if strings.HasPrefix(normalizedRaw, "opus") {
parsed := extractBitrateFromQuality(normalizedRaw, 256)
finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates)
return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate))
}
if strings.HasPrefix(normalizedRaw, "mp3") {
parsed := extractBitrateFromQuality(normalizedRaw, 320)
finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates)
return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate))
}
// Backward compatibility for legacy symbolic values.
switch normalizedRaw {
case "opus_256", "opus256", "opus":
return "opus", 256, YouTubeQualityOpus256
case "opus_320", "opus320":
return "opus", 320, YouTubeQualityOpus320
case "opus_128", "opus128":
return "opus", 128, YouTubeQualityOpus128
case "mp3_320", "mp3320", "mp3", "":
return "mp3", 320, YouTubeQualityMP3320
case "mp3_256", "mp3256":
return "mp3", 256, YouTubeQualityMP3256
case "mp3_128", "mp3128":
return "mp3", 128, YouTubeQualityMP3128
default:
return "mp3", 320, YouTubeQualityMP3320
}
}
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
searchQuery := url.QueryEscape(query)
GoLog("[YouTube] Search query: %s\n", query)
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
return youtubeMusicURL, nil
}
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
y.mu.Lock()
defer y.mu.Unlock()
audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality))
audioBitrate := strconv.Itoa(bitrate)
// Try SpotubeDL first (primary)
var spotubeErr error
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
if extractErr == nil {
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
videoID, audioFormat, audioBitrate)
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
if err == nil {
return resp, nil
}
spotubeErr = err
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
} else {
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
}
// Fallback: direct Cobalt API (api.qwkuns.me)
cobaltURL := toYouTubeMusicURL(youtubeURL)
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
cobaltURL, audioFormat, audioBitrate)
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
if err != nil {
if spotubeErr != nil {
return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err)
}
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
}
return resp, nil
}
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{
URL: videoURL,
AudioFormat: audioFormat,
AudioBitrate: audioBitrate,
DownloadMode: "audio",
FilenameStyle: "basic",
DisableMetadata: true,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("cobalt API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
}
var cobaltResp CobaltResponse
if err := json.Unmarshal(body, &cobaltResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
}
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
}
if cobaltResp.URL == "" {
return nil, fmt.Errorf("no download URL in response")
}
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
return &cobaltResp, nil
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests.
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
engines := []string{"v1"}
if strings.EqualFold(audioFormat, "mp3") {
engines = append(engines, "v3", "v2")
}
var lastErr error
for _, engine := range engines {
resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine)
if err == nil {
return resp, nil
}
lastErr = err
GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err)
}
if lastErr == nil {
lastErr = fmt.Errorf("no SpotubeDL engine available")
}
return nil, lastErr
}
func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) {
apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s",
spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate))
GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("spotubedl request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
Status string `json:"status"`
Error string `json:"error"`
Message string `json:"message"`
Filename string `json:"filename"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
}
downloadURL := strings.TrimSpace(result.URL)
if downloadURL == "" {
if result.Error != "" {
return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error)
}
if result.Message != "" {
return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message)
}
return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine)
}
if strings.HasPrefix(downloadURL, "/") {
downloadURL = spotubeBaseURL + downloadURL
}
if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") {
return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL)
}
filename := strings.TrimSpace(result.Filename)
if filename == "" {
if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil {
if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" {
if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil {
filename = decodedFilename
} else {
filename = queryFilename
}
}
}
}
GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine)
return &CobaltResponse{
Status: "tunnel",
URL: downloadURL,
Filename: filename,
}, nil
}
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[YouTube] Download completed: %d bytes written\n", written)
return nil
}
func BuildYouTubeSearchURL(trackName, artistName string) string {
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
}
func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func isYouTubeVideoID(s string) bool {
if len(s) != 11 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
func IsYouTubeURL(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "youtube.com") ||
strings.Contains(lower, "youtu.be") ||
strings.Contains(lower, "music.youtube.com")
}
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
func toYouTubeMusicURL(rawURL string) string {
videoID, err := ExtractYouTubeVideoID(rawURL)
if err != nil {
return rawURL
}
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func ExtractYouTubeVideoID(urlStr string) (string, error) {
if strings.Contains(urlStr, "youtu.be/") {
parts := strings.Split(urlStr, "youtu.be/")
if len(parts) >= 2 {
videoID := strings.Split(parts[1], "?")[0]
videoID = strings.Split(videoID, "&")[0]
return strings.TrimSpace(videoID), nil
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
return "", fmt.Errorf("could not extract video ID from URL")
}
// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch
// to find a track by artist + title. It filters for tracks only (not videos,
// albums, or playlists) and returns the YouTube Music watch URL for the first
// matching track, or "" if nothing was found.
func searchYouTubeMusicViaExtension(artistName, trackName string) string {
extManager := GetExtensionManager()
searchProviders := extManager.GetSearchProviders()
// Find the ytmusic-spotiflac extension
var ytProvider *ExtensionProviderWrapper
for _, p := range searchProviders {
if p.extension.ID == "ytmusic-spotiflac" {
ytProvider = p
break
}
}
if ytProvider == nil {
GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n")
return ""
}
query := strings.TrimSpace(artistName + " " + trackName)
if query == "" {
return ""
}
GoLog("[YouTube] Searching YT Music extension for: %s\n", query)
results, err := ytProvider.CustomSearch(query, map[string]interface{}{
"filter": "tracks",
})
if err != nil {
GoLog("[YouTube] YT Music extension search failed: %v\n", err)
return ""
}
// Find the first track result (item_type == "track" with a valid video ID)
for _, track := range results {
if track.ItemType != "" && track.ItemType != "track" {
continue
}
videoID := strings.TrimSpace(track.ID)
if videoID == "" {
continue
}
if isYouTubeVideoID(videoID) {
return BuildYouTubeWatchURL(videoID)
}
}
GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query)
return ""
}
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader()
format, bitrate, quality := parseYouTubeQualityInput(req.Quality)
// URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC)
var youtubeURL string
var lookupErr error
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
}
// Try YT Music extension search first (if installed) - more accurate, tracks only
if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") {
youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName)
if youtubeURL != "" {
GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL)
}
}
// Fallback: Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
}
}
// Fallback: Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
}
}
// Fallback: Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
youtubeURL = availability.YouTubeURL
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
} else if isrcErr != nil {
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
}
}
// Cobalt requires direct video URLs, not search URLs
if youtubeURL == "" {
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
}
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
if err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
ext := ".mp3"
if format == "opus" {
ext = ".opus"
}
// Some SpotubeDL engines may return a different output container than requested.
// Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension.
if cobaltResp != nil && cobaltResp.Filename != "" {
lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename))
switch {
case strings.HasSuffix(lowerName, ".mp3"):
ext = ".mp3"
format = "mp3"
case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"):
ext = ".opus"
format = "opus"
}
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"date": req.ReleaseDate,
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ext
var outputPath string
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
outputPath = req.OutputDir + "/" + filename
}
GoLog("[YouTube] Downloading to: %s\n", outputPath)
var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
lyricsLRC := ""
var coverData []byte
if parallelResult != nil {
if parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
}
if parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
}
}
return YouTubeDownloadResult{
FilePath: outputPath,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Format: format,
Bitrate: bitrate,
LyricsLRC: lyricsLRC,
CoverData: coverData,
}, nil
}
-54
View File
@@ -1,54 +0,0 @@
package gobackend
import "testing"
func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("opus_160")
if format != "opus" {
t.Fatalf("expected opus format, got %s", format)
}
if bitrate != 128 {
t.Fatalf("expected 128 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityOpus128 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized)
}
}
func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("mp3_192")
if format != "mp3" {
t.Fatalf("expected mp3 format, got %s", format)
}
if bitrate != 256 {
t.Fatalf("expected 256 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityMP3256 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized)
}
}
func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) {
_, opusBitrate, _ := parseYouTubeQualityInput("opus_999")
if opusBitrate != 320 {
t.Fatalf("expected opus normalization to 320, got %d", opusBitrate)
}
_, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1")
if mp3Bitrate != 128 {
t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate)
}
}
func TestParseYouTubeQualityInput_Opus320(t *testing.T) {
format, bitrate, normalized := parseYouTubeQualityInput("opus_320")
if format != "opus" {
t.Fatalf("expected opus format, got %s", format)
}
if bitrate != 320 {
t.Fatalf("expected 320 bitrate, got %d", bitrate)
}
if normalized != YouTubeQualityOpus320 {
t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized)
}
}
+3 -3
View File
@@ -3,14 +3,14 @@ import 'package:flutter/foundation.dart';
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.9.0'; static const String version = '4.1.0';
static const String buildNumber = '115'; static const String buildNumber = '117';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release. /// Shows "Internal" in debug builds, actual version in release.
static String get displayVersion => kDebugMode ? 'Internal' : version; static String get displayVersion => kDebugMode ? 'Internal' : version;
static const String appName = 'SpotiFLAC'; static const String appName = 'SpotiFLAC Mobile';
static const String copyright = '© 2026 SpotiFLAC'; static const String copyright = '© 2026 SpotiFLAC';
static const String mobileAuthor = 'zarzet'; static const String mobileAuthor = 'zarzet';
+234 -18
View File
@@ -1432,6 +1432,66 @@ abstract class AppLocalizations {
/// **'Playlists'** /// **'Playlists'**
String get searchPlaylists; String get searchPlaylists;
/// Bottom sheet title for search sort options
///
/// In en, this message translates to:
/// **'Sort Results'**
String get searchSortTitle;
/// Sort option - default API order
///
/// In en, this message translates to:
/// **'Default'**
String get searchSortDefault;
/// Sort option - title ascending
///
/// In en, this message translates to:
/// **'Title (A-Z)'**
String get searchSortTitleAZ;
/// Sort option - title descending
///
/// In en, this message translates to:
/// **'Title (Z-A)'**
String get searchSortTitleZA;
/// Sort option - artist ascending
///
/// In en, this message translates to:
/// **'Artist (A-Z)'**
String get searchSortArtistAZ;
/// Sort option - artist descending
///
/// In en, this message translates to:
/// **'Artist (Z-A)'**
String get searchSortArtistZA;
/// Sort option - shortest duration first
///
/// In en, this message translates to:
/// **'Duration (Shortest)'**
String get searchSortDurationShort;
/// Sort option - longest duration first
///
/// In en, this message translates to:
/// **'Duration (Longest)'**
String get searchSortDurationLong;
/// Sort option - oldest release first
///
/// In en, this message translates to:
/// **'Release Date (Oldest)'**
String get searchSortDateOldest;
/// Sort option - newest release first
///
/// In en, this message translates to:
/// **'Release Date (Newest)'**
String get searchSortDateNewest;
/// Tooltip - play button /// Tooltip - play button
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2662,24 +2722,6 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'** /// **'Actual quality depends on track availability from the service'**
String get qualityNote; String get qualityNote;
/// Note for YouTube service explaining lossy-only quality
///
/// In en, this message translates to:
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Title for YouTube Opus bitrate setting
///
/// In en, this message translates to:
/// **'YouTube Opus Bitrate'**
String get youtubeOpusBitrateTitle;
/// Title for YouTube MP3 bitrate setting
///
/// In en, this message translates to:
/// **'YouTube MP3 Bitrate'**
String get youtubeMp3BitrateTitle;
/// Setting - show quality picker /// Setting - show quality picker
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2860,6 +2902,18 @@ abstract class AppLocalizations {
/// **'Artist/Album/ and Artist/Singles/'** /// **'Artist/Album/ and Artist/Singles/'**
String get albumFolderArtistAlbumSinglesSubtitle; String get albumFolderArtistAlbumSinglesSubtitle;
/// Album folder option with singles directly in artist folder
///
/// In en, this message translates to:
/// **'Artist / Album (Singles flat)'**
String get albumFolderArtistAlbumFlat;
/// Folder structure example for flat singles
///
/// In en, this message translates to:
/// **'Artist/Album/ and Artist/song.flac'**
String get albumFolderArtistAlbumFlatSubtitle;
/// Button - delete selected tracks /// Button - delete selected tracks
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -5084,6 +5138,168 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Empty only'** /// **'Empty only'**
String get editMetadataSelectEmpty; String get editMetadataSelectEmpty;
/// Header for active downloads section with count
///
/// In en, this message translates to:
/// **'Downloading ({count})'**
String queueDownloadingCount(int count);
/// Header label for downloaded items section in library
///
/// In en, this message translates to:
/// **'Downloaded'**
String get queueDownloadedHeader;
/// Shown while filter results are being computed
///
/// In en, this message translates to:
/// **'Filtering...'**
String get queueFilteringIndicator;
/// Track count label with plural support
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
String queueTrackCount(int count);
/// Album count label with plural support
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 album} other{{count} albums}}'**
String queueAlbumCount(int count);
/// Empty state title when no album downloads exist
///
/// In en, this message translates to:
/// **'No album downloads'**
String get queueEmptyAlbums;
/// Empty state subtitle for album downloads
///
/// In en, this message translates to:
/// **'Download multiple tracks from an album to see them here'**
String get queueEmptyAlbumsSubtitle;
/// Empty state title when no single track downloads exist
///
/// In en, this message translates to:
/// **'No single downloads'**
String get queueEmptySingles;
/// Empty state subtitle for single track downloads
///
/// In en, this message translates to:
/// **'Single track downloads will appear here'**
String get queueEmptySinglesSubtitle;
/// Empty state title when download history is empty
///
/// In en, this message translates to:
/// **'No download history'**
String get queueEmptyHistory;
/// Empty state subtitle for download history
///
/// In en, this message translates to:
/// **'Downloaded tracks will appear here'**
String get queueEmptyHistorySubtitle;
/// Shown when all playlists are selected in selection mode
///
/// In en, this message translates to:
/// **'All playlists selected'**
String get selectionAllPlaylistsSelected;
/// Hint shown in playlist selection mode
///
/// In en, this message translates to:
/// **'Tap playlists to select'**
String get selectionTapPlaylistsToSelect;
/// Hint shown when no playlists are selected for deletion
///
/// In en, this message translates to:
/// **'Select playlists to delete'**
String get selectionSelectPlaylistsToDelete;
/// Title for audio analysis section
///
/// In en, this message translates to:
/// **'Audio Quality Analysis'**
String get audioAnalysisTitle;
/// Description for audio analysis tap-to-analyze prompt
///
/// In en, this message translates to:
/// **'Verify lossless quality with spectrum analysis'**
String get audioAnalysisDescription;
/// Loading text while analyzing audio
///
/// In en, this message translates to:
/// **'Analyzing audio...'**
String get audioAnalysisAnalyzing;
/// Sample rate metric label
///
/// In en, this message translates to:
/// **'Sample Rate'**
String get audioAnalysisSampleRate;
/// Bit depth metric label
///
/// In en, this message translates to:
/// **'Bit Depth'**
String get audioAnalysisBitDepth;
/// Channels metric label
///
/// In en, this message translates to:
/// **'Channels'**
String get audioAnalysisChannels;
/// Duration metric label
///
/// In en, this message translates to:
/// **'Duration'**
String get audioAnalysisDuration;
/// Nyquist frequency metric label
///
/// In en, this message translates to:
/// **'Nyquist'**
String get audioAnalysisNyquist;
/// File size metric label
///
/// In en, this message translates to:
/// **'Size'**
String get audioAnalysisFileSize;
/// Dynamic range metric label
///
/// In en, this message translates to:
/// **'Dynamic Range'**
String get audioAnalysisDynamicRange;
/// Peak amplitude metric label
///
/// In en, this message translates to:
/// **'Peak'**
String get audioAnalysisPeak;
/// RMS level metric label
///
/// In en, this message translates to:
/// **'RMS'**
String get audioAnalysisRms;
/// Total samples metric label
///
/// In en, this message translates to:
/// **'Samples'**
String get audioAnalysisSamples;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+139 -10
View File
@@ -772,6 +772,36 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlisten'; String get searchPlaylists => 'Playlisten';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Abspielen'; String get tooltipPlay => 'Abspielen';
@@ -1449,16 +1479,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab'; 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab';
@override
String get youtubeQualityNote =>
'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Qualität vor Download fragen'; String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
@@ -1558,6 +1578,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Künstler/Album/ und Künstler/Singles/'; 'Künstler/Album/ und Künstler/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen'; String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
@@ -2995,4 +3022,106 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -10
View File
@@ -759,6 +759,36 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlists'; String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Play'; String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,4 +2990,106 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -20
View File
@@ -759,6 +759,36 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlists'; String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Play'; String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -4334,16 +4463,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
String get qualityNote => String get qualityNote =>
'La calidad real depende de la disponibilidad de la pista del servicio'; 'La calidad real depende de la disponibilidad de la pista del servicio';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Preguntar antes de descargar'; String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
+139 -10
View File
@@ -761,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlists'; String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Play'; String get tooltipPlay => 'Play';
@@ -1427,16 +1457,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1534,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2964,4 +2991,106 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -10
View File
@@ -759,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlists'; String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Play'; String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2962,4 +2989,106 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -10
View File
@@ -762,6 +762,36 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlist'; String get searchPlaylists => 'Playlist';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Putar'; String get tooltipPlay => 'Putar';
@@ -1433,16 +1463,6 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@override
String get youtubeQualityNote =>
'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.';
@override
String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus';
@override
String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube';
@override @override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1541,6 +1561,13 @@ class AppLocalizationsId extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artis/Album/ dan Artis/Single/'; 'Artis/Album/ dan Artis/Single/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih'; String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -2972,4 +2999,106 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -10
View File
@@ -754,6 +754,36 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get searchPlaylists => 'プレイリスト'; String get searchPlaylists => 'プレイリスト';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => '再生'; String get tooltipPlay => '再生';
@@ -1414,16 +1444,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート';
@override @override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1519,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => '選択済みを削除'; String get downloadedAlbumDeleteSelected => '選択済みを削除';
@@ -2949,4 +2976,106 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -10
View File
@@ -741,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get searchPlaylists => '재생목록들'; String get searchPlaylists => '재생목록들';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => '재생'; String get tooltipPlay => '재생';
@@ -1405,16 +1435,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1512,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2942,4 +2969,106 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -10
View File
@@ -759,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlists'; String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Play'; String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2962,4 +2989,106 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -20
View File
@@ -759,6 +759,36 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlists'; String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Play'; String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -4331,16 +4460,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get qualityNote => String get qualityNote =>
'A qualidade real depende da faixa que estiver disponível no serviço'; 'A qualidade real depende da faixa que estiver disponível no serviço';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar'; String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
+139 -10
View File
@@ -773,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get searchPlaylists => 'Плейлисты'; String get searchPlaylists => 'Плейлисты';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Воспроизвести'; String get tooltipPlay => 'Воспроизвести';
@@ -1450,16 +1480,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе'; 'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
@override
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
@override @override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1561,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Исполнитель/Альбом и Исполнитель/Сингл/'; 'Исполнитель/Альбом и Исполнитель/Сингл/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные'; String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -3022,4 +3049,106 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -10
View File
@@ -764,6 +764,36 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get searchPlaylists => 'Çalma Listeleri'; String get searchPlaylists => 'Çalma Listeleri';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Oynat'; String get tooltipPlay => 'Oynat';
@@ -1431,16 +1461,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1538,6 +1558,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2968,4 +2995,106 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
+139 -30
View File
@@ -759,6 +759,36 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get searchPlaylists => 'Playlists'; String get searchPlaylists => 'Playlists';
@override
String get searchSortTitle => 'Sort Results';
@override
String get searchSortDefault => 'Default';
@override
String get searchSortTitleAZ => 'Title (A-Z)';
@override
String get searchSortTitleZA => 'Title (Z-A)';
@override
String get searchSortArtistAZ => 'Artist (A-Z)';
@override
String get searchSortArtistZA => 'Artist (Z-A)';
@override
String get searchSortDurationShort => 'Duration (Shortest)';
@override
String get searchSortDurationLong => 'Duration (Longest)';
@override
String get searchSortDateOldest => 'Release Date (Oldest)';
@override
String get searchSortDateNewest => 'Release Date (Newest)';
@override @override
String get tooltipPlay => 'Play'; String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle => String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/'; 'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override @override
String get downloadedAlbumDeleteSelected => 'Delete Selected'; String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get editMetadataSelectEmpty => 'Empty only'; String get editMetadataSelectEmpty => 'Empty only';
@override
String queueDownloadingCount(int count) {
return 'Downloading ($count)';
}
@override
String get queueDownloadedHeader => 'Downloaded';
@override
String get queueFilteringIndicator => 'Filtering...';
@override
String queueTrackCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count tracks',
one: '1 track',
);
return '$_temp0';
}
@override
String queueAlbumCount(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count albums',
one: '1 album',
);
return '$_temp0';
}
@override
String get queueEmptyAlbums => 'No album downloads';
@override
String get queueEmptyAlbumsSubtitle =>
'Download multiple tracks from an album to see them here';
@override
String get queueEmptySingles => 'No single downloads';
@override
String get queueEmptySinglesSubtitle =>
'Single track downloads will appear here';
@override
String get queueEmptyHistory => 'No download history';
@override
String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here';
@override
String get selectionAllPlaylistsSelected => 'All playlists selected';
@override
String get selectionTapPlaylistsToSelect => 'Tap playlists to select';
@override
String get selectionSelectPlaylistsToDelete => 'Select playlists to delete';
@override
String get audioAnalysisTitle => 'Audio Quality Analysis';
@override
String get audioAnalysisDescription =>
'Verify lossless quality with spectrum analysis';
@override
String get audioAnalysisAnalyzing => 'Analyzing audio...';
@override
String get audioAnalysisSampleRate => 'Sample Rate';
@override
String get audioAnalysisBitDepth => 'Bit Depth';
@override
String get audioAnalysisChannels => 'Channels';
@override
String get audioAnalysisDuration => 'Duration';
@override
String get audioAnalysisNyquist => 'Nyquist';
@override
String get audioAnalysisFileSize => 'Size';
@override
String get audioAnalysisDynamicRange => 'Dynamic Range';
@override
String get audioAnalysisPeak => 'Peak';
@override
String get audioAnalysisRms => 'RMS';
@override
String get audioAnalysisSamples => 'Samples';
} }
/// The translations for Chinese, as used in China (`zh_CN`). /// The translations for Chinese, as used in China (`zh_CN`).
@@ -4297,16 +4426,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -6703,16 +6822,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate';
@override
String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Qualität vor Download fragen", "downloadAskBeforeDownload": "Qualität vor Download fragen",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
+172 -12
View File
@@ -999,6 +999,46 @@
"@searchPlaylists": { "@searchPlaylists": {
"description": "Search result category - playlists" "description": "Search result category - playlists"
}, },
"searchSortTitle": "Sort Results",
"@searchSortTitle": {
"description": "Bottom sheet title for search sort options"
},
"searchSortDefault": "Default",
"@searchSortDefault": {
"description": "Sort option - default API order"
},
"searchSortTitleAZ": "Title (A-Z)",
"@searchSortTitleAZ": {
"description": "Sort option - title ascending"
},
"searchSortTitleZA": "Title (Z-A)",
"@searchSortTitleZA": {
"description": "Sort option - title descending"
},
"searchSortArtistAZ": "Artist (A-Z)",
"@searchSortArtistAZ": {
"description": "Sort option - artist ascending"
},
"searchSortArtistZA": "Artist (Z-A)",
"@searchSortArtistZA": {
"description": "Sort option - artist descending"
},
"searchSortDurationShort": "Duration (Shortest)",
"@searchSortDurationShort": {
"description": "Sort option - shortest duration first"
},
"searchSortDurationLong": "Duration (Longest)",
"@searchSortDurationLong": {
"description": "Sort option - longest duration first"
},
"searchSortDateOldest": "Release Date (Oldest)",
"@searchSortDateOldest": {
"description": "Sort option - oldest release first"
},
"searchSortDateNewest": "Release Date (Newest)",
"@searchSortDateNewest": {
"description": "Sort option - newest release first"
},
"tooltipPlay": "Play", "tooltipPlay": "Play",
"@tooltipPlay": { "@tooltipPlay": {
"description": "Tooltip - play button" "description": "Tooltip - play button"
@@ -1869,18 +1909,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
@@ -2001,6 +2029,14 @@
"@albumFolderArtistAlbumSinglesSubtitle": { "@albumFolderArtistAlbumSinglesSubtitle": {
"description": "Folder structure example" "description": "Folder structure example"
}, },
"albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)",
"@albumFolderArtistAlbumFlat": {
"description": "Album folder option with singles directly in artist folder"
},
"albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac",
"@albumFolderArtistAlbumFlatSubtitle": {
"description": "Folder structure example for flat singles"
},
"downloadedAlbumDeleteSelected": "Delete Selected", "downloadedAlbumDeleteSelected": "Delete Selected",
"@downloadedAlbumDeleteSelected": { "@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks" "description": "Button - delete selected tracks"
@@ -3903,5 +3939,129 @@
"editMetadataSelectEmpty": "Empty only", "editMetadataSelectEmpty": "Empty only",
"@editMetadataSelectEmpty": { "@editMetadataSelectEmpty": {
"description": "Button to select only fields that are currently empty" "description": "Button to select only fields that are currently empty"
},
"queueDownloadingCount": "Downloading ({count})",
"@queueDownloadingCount": {
"description": "Header for active downloads section with count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueDownloadedHeader": "Downloaded",
"@queueDownloadedHeader": {
"description": "Header label for downloaded items section in library"
},
"queueFilteringIndicator": "Filtering...",
"@queueFilteringIndicator": {
"description": "Shown while filter results are being computed"
},
"queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}",
"@queueTrackCount": {
"description": "Track count label with plural support",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}",
"@queueAlbumCount": {
"description": "Album count label with plural support",
"placeholders": {
"count": {
"type": "int"
}
}
},
"queueEmptyAlbums": "No album downloads",
"@queueEmptyAlbums": {
"description": "Empty state title when no album downloads exist"
},
"queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here",
"@queueEmptyAlbumsSubtitle": {
"description": "Empty state subtitle for album downloads"
},
"queueEmptySingles": "No single downloads",
"@queueEmptySingles": {
"description": "Empty state title when no single track downloads exist"
},
"queueEmptySinglesSubtitle": "Single track downloads will appear here",
"@queueEmptySinglesSubtitle": {
"description": "Empty state subtitle for single track downloads"
},
"queueEmptyHistory": "No download history",
"@queueEmptyHistory": {
"description": "Empty state title when download history is empty"
},
"queueEmptyHistorySubtitle": "Downloaded tracks will appear here",
"@queueEmptyHistorySubtitle": {
"description": "Empty state subtitle for download history"
},
"selectionAllPlaylistsSelected": "All playlists selected",
"@selectionAllPlaylistsSelected": {
"description": "Shown when all playlists are selected in selection mode"
},
"selectionTapPlaylistsToSelect": "Tap playlists to select",
"@selectionTapPlaylistsToSelect": {
"description": "Hint shown in playlist selection mode"
},
"selectionSelectPlaylistsToDelete": "Select playlists to delete",
"@selectionSelectPlaylistsToDelete": {
"description": "Hint shown when no playlists are selected for deletion"
},
"audioAnalysisTitle": "Audio Quality Analysis",
"@audioAnalysisTitle": {
"description": "Title for audio analysis section"
},
"audioAnalysisDescription": "Verify lossless quality with spectrum analysis",
"@audioAnalysisDescription": {
"description": "Description for audio analysis tap-to-analyze prompt"
},
"audioAnalysisAnalyzing": "Analyzing audio...",
"@audioAnalysisAnalyzing": {
"description": "Loading text while analyzing audio"
},
"audioAnalysisSampleRate": "Sample Rate",
"@audioAnalysisSampleRate": {
"description": "Sample rate metric label"
},
"audioAnalysisBitDepth": "Bit Depth",
"@audioAnalysisBitDepth": {
"description": "Bit depth metric label"
},
"audioAnalysisChannels": "Channels",
"@audioAnalysisChannels": {
"description": "Channels metric label"
},
"audioAnalysisDuration": "Duration",
"@audioAnalysisDuration": {
"description": "Duration metric label"
},
"audioAnalysisNyquist": "Nyquist",
"@audioAnalysisNyquist": {
"description": "Nyquist frequency metric label"
},
"audioAnalysisFileSize": "Size",
"@audioAnalysisFileSize": {
"description": "File size metric label"
},
"audioAnalysisDynamicRange": "Dynamic Range",
"@audioAnalysisDynamicRange": {
"description": "Dynamic range metric label"
},
"audioAnalysisPeak": "Peak",
"@audioAnalysisPeak": {
"description": "Peak amplitude metric label"
},
"audioAnalysisRms": "RMS",
"@audioAnalysisRms": {
"description": "RMS level metric label"
},
"audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": {
"description": "Total samples metric label"
} }
} }
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Preguntar antes de descargar", "downloadAskBeforeDownload": "Preguntar antes de descargar",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "Bitrate YouTube Opus",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "Kecepatan Bit MP3 YouTube",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Tanya Sebelum Unduh", "downloadAskBeforeDownload": "Tanya Sebelum Unduh",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus のビットレート",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 のビットレート",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "ダウンロード前に確認する", "downloadAskBeforeDownload": "ダウンロード前に確認する",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Perguntar qualidade antes de baixar", "downloadAskBeforeDownload": "Perguntar qualidade antes de baixar",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "Битрейт YouTube Opus",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "Битрейт YouTube MP3",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Спрашивать перед скачиванием", "downloadAskBeforeDownload": "Спрашивать перед скачиванием",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": { "@qualityNote": {
"description": "Note about quality availability" "description": "Note about quality availability"
}, },
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {
"description": "Note for YouTube service explaining lossy-only quality"
},
"youtubeOpusBitrateTitle": "YouTube Opus Bitrate",
"@youtubeOpusBitrateTitle": {
"description": "Title for YouTube Opus bitrate setting"
},
"youtubeMp3BitrateTitle": "YouTube MP3 Bitrate",
"@youtubeMp3BitrateTitle": {
"description": "Title for YouTube MP3 bitrate setting"
},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": { "@downloadAskBeforeDownload": {
"description": "Setting - show quality picker" "description": "Setting - show quality picker"
-3
View File
@@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
if (settings.localLibraryPath.isEmpty) return; if (settings.localLibraryPath.isEmpty) return;
if (settings.localLibraryAutoScan == 'off') return; if (settings.localLibraryAutoScan == 'off') return;
// Don't start a scan if one is already running.
final libraryState = ref.read(localLibraryProvider); final libraryState = ref.read(localLibraryProvider);
if (libraryState.isScanning) return; if (libraryState.isScanning) return;
// Determine cooldown based on auto-scan mode.
final now = DateTime.now(); final now = DateTime.now();
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final lastScanned = readLocalLibraryLastScannedAt(prefs); final lastScanned = readLocalLibraryLastScannedAt(prefs);
@@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
} }
} }
// All checks passed -- start an incremental scan.
final iosBookmark = settings.localLibraryBookmark; final iosBookmark = settings.localLibraryBookmark;
ref ref
.read(localLibraryProvider.notifier) .read(localLibraryProvider.notifier)
-10
View File
@@ -42,10 +42,6 @@ class AppSettings {
final String lyricsMode; final String lyricsMode;
final String final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final int
youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps)
final int
youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps)
final bool final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool final bool
@@ -121,8 +117,6 @@ class AppSettings {
this.locale = 'system', this.locale = 'system',
this.lyricsMode = 'embed', this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320', this.tidalHighFormat = 'mp3_320',
this.youtubeOpusBitrate = 256,
this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false, this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false, this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any', this.downloadNetworkMode = 'any',
@@ -189,8 +183,6 @@ class AppSettings {
String? locale, String? locale,
String? lyricsMode, String? lyricsMode,
String? tidalHighFormat, String? tidalHighFormat,
int? youtubeOpusBitrate,
int? youtubeMp3Bitrate,
bool? useAllFilesAccess, bool? useAllFilesAccess,
bool? autoExportFailedDownloads, bool? autoExportFailedDownloads,
String? downloadNetworkMode, String? downloadNetworkMode,
@@ -257,8 +249,6 @@ class AppSettings {
locale: locale ?? this.locale, locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode, lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads, autoExportFailedDownloads ?? this.autoExportFailedDownloads,
-4
View File
@@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
locale: json['locale'] as String? ?? 'system', locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed', lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256,
youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320,
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
autoExportFailedDownloads: autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false, json['autoExportFailedDownloads'] as bool? ?? false,
@@ -125,8 +123,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'locale': instance.locale, 'locale': instance.locale,
'lyricsMode': instance.lyricsMode, 'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat, 'tidalHighFormat': instance.tidalHighFormat,
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess, 'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode, 'downloadNetworkMode': instance.downloadNetworkMode,
+431 -217
View File
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
@@ -262,8 +263,14 @@ class DownloadHistoryState {
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> { class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _safRepairBatchSize = 20; static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60; static const int _safRepairMaxPerLaunch = 60;
static const int _orphanCleanupMaxPerLaunch = 80;
static const int _audioMetadataBackfillMaxPerLaunch = 24; static const int _audioMetadataBackfillMaxPerLaunch = 24;
static const _startupMaintenanceDelay = Duration(seconds: 2); static const _startupMaintenanceDelay = Duration(seconds: 4);
static const _startupMaintenanceStepGap = Duration(milliseconds: 250);
static const _startupSafRepairCursorKey =
'history_startup_saf_repair_cursor_v1';
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
final HistoryDatabase _db = HistoryDatabase.instance; final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false; bool _isLoaded = false;
bool _isSafRepairInProgress = false; bool _isSafRepairInProgress = false;
@@ -320,20 +327,29 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
unawaited( unawaited(
Future<void>.delayed(_startupMaintenanceDelay, () async { Future<void>.delayed(_startupMaintenanceDelay, () async {
try { try {
final prefs = await SharedPreferences.getInstance();
if (Platform.isAndroid) { if (Platform.isAndroid) {
await _repairMissingSafEntries( await _repairMissingSafEntries(
initialItems, initialItems,
maxItems: _safRepairMaxPerLaunch, maxItems: _safRepairMaxPerLaunch,
prefs: prefs,
); );
await Future<void>.delayed(_startupMaintenanceStepGap);
} }
await cleanupOrphanedDownloads(); await _cleanupOrphanedDownloadsIncremental(
maxItems: _orphanCleanupMaxPerLaunch,
prefs: prefs,
);
await Future<void>.delayed(_startupMaintenanceStepGap);
final currentItems = state.items; final currentItems = state.items;
if (currentItems.isNotEmpty) { if (currentItems.isNotEmpty) {
await _backfillAudioMetadata( await _backfillAudioMetadata(
currentItems, currentItems,
maxItems: _audioMetadataBackfillMaxPerLaunch, maxItems: _audioMetadataBackfillMaxPerLaunch,
prefs: prefs,
); );
} }
} catch (e, stack) { } catch (e, stack) {
@@ -344,6 +360,30 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
); );
} }
int _readStartupCursor(SharedPreferences prefs, String key, int totalCount) {
if (totalCount <= 0) {
return 0;
}
final cursor = prefs.getInt(key) ?? 0;
if (cursor < 0 || cursor >= totalCount) {
return 0;
}
return cursor;
}
Future<void> _writeStartupCursor(
SharedPreferences prefs,
String key,
int nextCursor,
int totalCount,
) async {
if (totalCount <= 0 || nextCursor <= 0 || nextCursor >= totalCount) {
await prefs.remove(key);
return;
}
await prefs.setInt(key, nextCursor);
}
String _fileNameFromUri(String uri) { String _fileNameFromUri(String uri) {
try { try {
final parsed = Uri.parse(uri); final parsed = Uri.parse(uri);
@@ -357,6 +397,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
Future<void> _repairMissingSafEntries( Future<void> _repairMissingSafEntries(
List<DownloadHistoryItem> items, { List<DownloadHistoryItem> items, {
required int maxItems, required int maxItems,
required SharedPreferences prefs,
}) async { }) async {
if (_isSafRepairInProgress || items.isEmpty) { if (_isSafRepairInProgress || items.isEmpty) {
return; return;
@@ -378,22 +419,40 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
continue; continue;
} }
candidateIndexes.add(i); candidateIndexes.add(i);
if (candidateIndexes.length >= maxItems) break;
} }
if (candidateIndexes.isEmpty) { if (candidateIndexes.isEmpty) {
await prefs.remove(_startupSafRepairCursorKey);
_isSafRepairInProgress = false;
return;
}
final startCursor = _readStartupCursor(
prefs,
_startupSafRepairCursorKey,
candidateIndexes.length,
);
final endCursor = (startCursor + maxItems).clamp(
0,
candidateIndexes.length,
);
final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor);
if (selectedIndexes.isEmpty) {
await prefs.remove(_startupSafRepairCursorKey);
_isSafRepairInProgress = false; _isSafRepairInProgress = false;
return; return;
} }
final updatedItems = [...items]; final updatedItems = [...items];
final persistedUpdates = <Map<String, dynamic>>[];
var changed = false; var changed = false;
var repairedCount = 0; var repairedCount = 0;
var verifiedCount = 0; var verifiedCount = 0;
try { try {
for (var c = 0; c < candidateIndexes.length; c++) { for (var c = 0; c < selectedIndexes.length; c++) {
final i = candidateIndexes[c]; final i = selectedIndexes[c];
final item = items[i]; final item = items[i];
final rawPath = item.filePath.trim(); final rawPath = item.filePath.trim();
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath); final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
@@ -408,7 +467,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
updatedItems[i] = verified; updatedItems[i] = verified;
changed = true; changed = true;
verifiedCount++; verifiedCount++;
await _db.upsert(verified.toJson()); persistedUpdates.add(verified.toJson());
continue; continue;
} }
} }
@@ -445,7 +504,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
updatedItems[i] = updated; updatedItems[i] = updated;
changed = true; changed = true;
repairedCount++; repairedCount++;
await _db.upsert(updated.toJson()); persistedUpdates.add(updated.toJson());
} catch (e) { } catch (e) {
_historyLog.w('Failed to repair SAF URI: $e'); _historyLog.w('Failed to repair SAF URI: $e');
} }
@@ -456,11 +515,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
} }
if (changed) { if (changed) {
await _db.upsertBatch(persistedUpdates);
state = state.copyWith(items: updatedItems); state = state.copyWith(items: updatedItems);
_historyLog.i( _historyLog.i(
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${candidateIndexes.length}', 'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}',
); );
} }
await _writeStartupCursor(
prefs,
_startupSafRepairCursorKey,
endCursor,
candidateIndexes.length,
);
} finally { } finally {
_isSafRepairInProgress = false; _isSafRepairInProgress = false;
} }
@@ -556,6 +622,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
Future<void> _backfillAudioMetadata( Future<void> _backfillAudioMetadata(
List<DownloadHistoryItem> items, { List<DownloadHistoryItem> items, {
required int maxItems, required int maxItems,
required SharedPreferences prefs,
}) async { }) async {
if (_isAudioMetadataBackfillInProgress || items.isEmpty) { if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
return; return;
@@ -563,15 +630,40 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_isAudioMetadataBackfillInProgress = true; _isAudioMetadataBackfillInProgress = true;
try { try {
final candidateIndexes = <int>[];
for (var i = 0; i < items.length; i++) {
if (_shouldBackfillAudioMetadata(items[i])) {
candidateIndexes.add(i);
}
}
if (candidateIndexes.isEmpty) {
await prefs.remove(_startupAudioCursorKey);
return;
}
final startCursor = _readStartupCursor(
prefs,
_startupAudioCursorKey,
candidateIndexes.length,
);
final endCursor = (startCursor + maxItems).clamp(
0,
candidateIndexes.length,
);
final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor);
if (selectedIndexes.isEmpty) {
await prefs.remove(_startupAudioCursorKey);
return;
}
List<DownloadHistoryItem>? updatedItems;
final persistedUpdates = <Map<String, dynamic>>[];
var refreshedCount = 0; var refreshedCount = 0;
for (final item in items) { for (final index in selectedIndexes) {
if (refreshedCount >= maxItems) { final item = items[index];
break;
}
if (!_shouldBackfillAudioMetadata(item)) {
continue;
}
final probed = await _probeAudioMetadata( final probed = await _probeAudioMetadata(
item.filePath, item.filePath,
@@ -598,15 +690,29 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
continue; continue;
} }
await updateAudioMetadataForItem( final updated = item.copyWith(
id: item.id,
quality: resolvedQuality, quality: resolvedQuality,
bitDepth: resolvedBitDepth, bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate, sampleRate: resolvedSampleRate,
); );
updatedItems ??= [...items];
updatedItems[index] = updated;
persistedUpdates.add(updated.toJson());
refreshedCount++; refreshedCount++;
} }
if (persistedUpdates.isNotEmpty && updatedItems != null) {
await _db.upsertBatch(persistedUpdates);
state = state.copyWith(items: updatedItems);
}
await _writeStartupCursor(
prefs,
_startupAudioCursorKey,
endCursor,
candidateIndexes.length,
);
if (refreshedCount > 0) { if (refreshedCount > 0) {
_historyLog.i( _historyLog.i(
'Audio metadata backfill refreshed $refreshedCount items', 'Audio metadata backfill refreshed $refreshedCount items',
@@ -768,9 +874,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
await _db.upsert(updated.toJson()); await _db.upsert(updated.toJson());
} }
/// Remove history entries where the file no longer exists on disk
/// Returns the number of orphaned entries removed
/// Audio file extensions that the app commonly produces or converts between.
static const _audioExtensions = [ static const _audioExtensions = [
'.flac', '.flac',
'.m4a', '.m4a',
@@ -781,11 +884,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
'.aac', '.aac',
]; ];
/// When the original file is missing, check whether a sibling with a
/// different audio extension exists (e.g. the user converted .flac .opus).
/// Returns the path of the first match found, or `null` if none exist.
Future<String?> _findConvertedSibling(String originalPath) async { Future<String?> _findConvertedSibling(String originalPath) async {
// Strip the current extension to get the base path.
final dotIndex = originalPath.lastIndexOf('.'); final dotIndex = originalPath.lastIndexOf('.');
if (dotIndex < 0) return null; if (dotIndex < 0) return null;
final basePath = originalPath.substring(0, dotIndex); final basePath = originalPath.substring(0, dotIndex);
@@ -801,11 +900,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return null; return null;
} }
Future<int> cleanupOrphanedDownloads() async { Future<
_historyLog.i('Starting orphaned downloads cleanup...'); ({
List<String> orphanedIds,
final entries = await _db.getAllEntriesWithPaths(); Map<String, String> replacementPaths,
Map<String, String> pathById,
})
>
_inspectOrphanedEntries(List<Map<String, dynamic>> entries) async {
final orphanedIds = <String>[]; final orphanedIds = <String>[];
final replacementPaths = <String, String>{};
final pathById = <String, String>{}; final pathById = <String, String>{};
const checkChunkSize = 16; const checkChunkSize = 16;
@@ -824,14 +928,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
try { try {
if (await fileExists(filePath)) return MapEntry(id, true); if (await fileExists(filePath)) return MapEntry(id, true);
// Original file missing -- check for a converted sibling.
final sibling = await _findConvertedSibling(filePath); final sibling = await _findConvertedSibling(filePath);
if (sibling != null) { if (sibling != null) {
_historyLog.i( _historyLog.i(
'Found converted sibling for $id: $filePath $sibling', 'Found converted sibling for $id: $filePath -> $sibling',
); );
// Update the stored path so future checks succeed immediately. replacementPaths[id] = sibling;
await _db.updateFilePath(id, sibling);
pathById[id] = sibling; pathById[id] = sibling;
return MapEntry(id, true); return MapEntry(id, true);
} }
@@ -853,21 +955,127 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
} }
} }
if (orphanedIds.isEmpty) { return (
orphanedIds: orphanedIds,
replacementPaths: replacementPaths,
pathById: pathById,
);
}
void _applyHistoryPathAndDeletionChanges({
required List<String> deletedIds,
required Map<String, String> replacementPaths,
}) {
if (deletedIds.isEmpty && replacementPaths.isEmpty) {
return;
}
final deletedSet = deletedIds.toSet();
final updatedItems = <DownloadHistoryItem>[];
for (final item in state.items) {
if (deletedSet.contains(item.id)) {
continue;
}
final replacementPath = replacementPaths[item.id];
if (replacementPath != null && replacementPath != item.filePath) {
updatedItems.add(item.copyWith(filePath: replacementPath));
} else {
updatedItems.add(item);
}
}
state = state.copyWith(items: updatedItems);
}
Future<int> _cleanupOrphanedDownloadsIncremental({
required int maxItems,
required SharedPreferences prefs,
}) async {
final cursor = prefs.getInt(_startupOrphanCursorKey) ?? 0;
final safeCursor = cursor < 0 ? 0 : cursor;
final entries = await _db.getEntriesWithPathsPage(
limit: maxItems,
offset: safeCursor,
);
if (entries.isEmpty) {
await prefs.remove(_startupOrphanCursorKey);
return 0;
}
final result = await _inspectOrphanedEntries(entries);
for (final replacement in result.replacementPaths.entries) {
await _db.updateFilePath(replacement.key, replacement.value);
}
final deletedCount = result.orphanedIds.isEmpty
? 0
: await _db.deleteByIds(result.orphanedIds);
_applyHistoryPathAndDeletionChanges(
deletedIds: result.orphanedIds,
replacementPaths: result.replacementPaths,
);
if (entries.length < maxItems) {
await prefs.remove(_startupOrphanCursorKey);
} else {
final nextCursor =
safeCursor + entries.length - result.orphanedIds.length;
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
}
if (deletedCount > 0 || result.replacementPaths.isNotEmpty) {
_historyLog.i(
'Startup orphan cleanup pass: removed=$deletedCount, repaired=${result.replacementPaths.length}, checked=${entries.length}',
);
}
return deletedCount;
}
Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...');
final orphanedIds = <String>[];
final replacementPaths = <String, String>{};
const pageSize = 256;
var offset = 0;
while (true) {
final entries = await _db.getEntriesWithPathsPage(
limit: pageSize,
offset: offset,
);
if (entries.isEmpty) {
break;
}
final result = await _inspectOrphanedEntries(entries);
orphanedIds.addAll(result.orphanedIds);
replacementPaths.addAll(result.replacementPaths);
if (entries.length < pageSize) {
break;
}
offset += entries.length - result.orphanedIds.length;
}
for (final replacement in replacementPaths.entries) {
await _db.updateFilePath(replacement.key, replacement.value);
}
if (orphanedIds.isEmpty && replacementPaths.isEmpty) {
_historyLog.i('No orphaned entries found'); _historyLog.i('No orphaned entries found');
return 0; return 0;
} }
final deletedCount = await _db.deleteByIds(orphanedIds); final deletedCount = orphanedIds.isEmpty
? 0
final orphanedSet = orphanedIds.toSet(); : await _db.deleteByIds(orphanedIds);
state = state.copyWith( _applyHistoryPathAndDeletionChanges(
items: state.items deletedIds: orphanedIds,
.where((item) => !orphanedSet.contains(item.id)) replacementPaths: replacementPaths,
.toList(),
); );
_historyLog.i('Cleaned up $deletedCount orphaned entries'); _historyLog.i(
'Cleaned up $deletedCount orphaned entries and repaired ${replacementPaths.length} paths',
);
return deletedCount; return deletedCount;
} }
@@ -1700,6 +1908,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
if (albumFolderStructure == 'artist_album_flat') {
if (isSingle) {
final artistPath = '$baseDir${Platform.pathSeparator}$artistName';
await _ensureDirExists(artistPath, label: 'Artist folder');
return artistPath;
} else {
final albumName = _sanitizeFolderName(track.albumName);
final albumPath =
'$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
await _ensureDirExists(albumPath, label: 'Artist Album folder');
return albumPath;
}
}
if (isSingle) { if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Singles folder'); await _ensureDirExists(singlesPath, label: 'Singles folder');
@@ -1920,15 +2142,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String _determineOutputExt(String quality, String service) { String _determineOutputExt(String quality, String service) {
if (service.toLowerCase() == 'youtube') {
if (quality.toLowerCase().contains('mp3')) {
return '.mp3';
}
return '.opus';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a'; return '.m4a';
} }
final q = quality.toLowerCase();
if (q.startsWith('opus')) return '.opus';
if (q.startsWith('mp3')) return '.mp3';
return '.flac'; return '.flac';
} }
@@ -2206,6 +2425,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_requestNativeCancel(id); _requestNativeCancel(id);
} }
void dismissItem(String id) {
final item = _findItemById(id);
if (item == null) return;
final isActive =
item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading ||
item.status == DownloadStatus.finalizing;
if (isActive) {
_pausePendingItemIds.remove(id);
_locallyCancelledItemIds.add(id);
_requestNativeCancel(id);
} else {
_locallyCancelledItemIds.remove(id);
}
final items = state.items.where((entry) => entry.id != id).toList();
final currentDownload = state.currentDownload?.id == id
? null
: state.currentDownload;
state = state.copyWith(items: items, currentDownload: currentDownload);
_saveQueueToStorage();
}
void clearCompleted() { void clearCompleted() {
final items = state.items final items = state.items
.where( .where(
@@ -2474,7 +2718,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$'); static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
String _upgradeToMaxQualityCover(String coverUrl) { String _upgradeToMaxQualityCover(String coverUrl) {
// Spotify CDN upgrade (hash-based size identifiers)
const spotifySize300 = 'ab67616d00001e02'; const spotifySize300 = 'ab67616d00001e02';
const spotifySize640 = 'ab67616d0000b273'; const spotifySize640 = 'ab67616d0000b273';
const spotifySizeMax = 'ab67616d000082c1'; const spotifySizeMax = 'ab67616d000082c1';
@@ -2487,7 +2730,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
result = result.replaceFirst(spotifySize640, spotifySizeMax); result = result.replaceFirst(spotifySize640, spotifySizeMax);
} }
// Deezer CDN upgrade (1000x1000 1800x1800)
if (result.contains('cdn-images.dzcdn.net')) { if (result.contains('cdn-images.dzcdn.net')) {
final upgraded = result.replaceFirst( final upgraded = result.replaceFirst(
_deezerSizeRegex, _deezerSizeRegex,
@@ -3168,7 +3410,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Future<void> _processQueue() async { Future<void> _processQueue() async {
if (state.isProcessing) return; if (state.isProcessing) return;
// Check network connectivity before starting
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
updateSettings(settings); updateSettings(settings);
final isSafMode = _isSafMode(settings); final isSafMode = _isSafMode(settings);
@@ -3228,7 +3469,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: musicDir.path); state = state.copyWith(outputDir: musicDir.path);
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path); ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) { } else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/)
_log.w( _log.w(
'iOS: Invalid output path detected (container root?), falling back to app Documents folder', 'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
); );
@@ -3250,7 +3490,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Output directory: ${state.outputDir}'); _log.d('Output directory: ${state.outputDir}');
} else { } else {
_log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})'); _log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})');
// Validate SAF permission is still accessible
try { try {
final testResult = await PlatformBridge.createSafFileFromPath( final testResult = await PlatformBridge.createSafFileFromPath(
treeUri: settings.downloadTreeUri, treeUri: settings.downloadTreeUri,
@@ -3259,16 +3498,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
mimeType: 'application/octet-stream', mimeType: 'application/octet-stream',
srcPath: '', srcPath: '',
); );
// If we got a result, permission is valid (file creation may fail but that's ok)
// If permission is revoked, this will throw
if (testResult != null) { if (testResult != null) {
// Clean up test file
await PlatformBridge.safDelete(testResult); await PlatformBridge.safDelete(testResult);
} }
} catch (e) { } catch (e) {
_log.e('SAF permission validation failed: $e'); _log.e('SAF permission validation failed: $e');
_log.w('SAF tree URI may be invalid or permission revoked'); _log.w('SAF tree URI may be invalid or permission revoked');
// Mark all queued items as failed
for (final item in state.items) { for (final item in state.items) {
if (item.status == DownloadStatus.queued) { if (item.status == DownloadStatus.queued) {
updateItemStatus( updateItemStatus(
@@ -3402,8 +3637,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
if (activeDownloads.isNotEmpty) { if (activeDownloads.isNotEmpty) {
// Re-check queue/settings periodically so concurrency changes
// (e.g. 1 -> 3) can take effect before any active item finishes.
await Future.any([ await Future.any([
Future.any(activeDownloads.values), Future.any(activeDownloads.values),
Future.delayed(_queueSchedulingInterval), Future.delayed(_queueSchedulingInterval),
@@ -3555,28 +3788,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
var quality = item.qualityOverride ?? state.audioQuality; var quality = item.qualityOverride ?? state.audioQuality;
if (item.service.toLowerCase() == 'youtube') {
final normalized = quality.toLowerCase();
final isYoutubeQuality =
normalized.startsWith('mp3_') || normalized.startsWith('opus_');
if (!isYoutubeQuality) {
final mp3Bitrate = (() {
const supported = [128, 256, 320];
var nearest = supported.first;
var nearestDistance = (settings.youtubeMp3Bitrate - nearest).abs();
for (final option in supported.skip(1)) {
final distance = (settings.youtubeMp3Bitrate - option).abs();
if (distance < nearestDistance ||
(distance == nearestDistance && option > nearest)) {
nearest = option;
nearestDistance = distance;
}
}
return nearest;
})();
quality = 'mp3_$mp3Bitrate';
}
}
final isSafMode = _isSafMode(settings); final isSafMode = _isSafMode(settings);
final relativeOutputDir = isSafMode final relativeOutputDir = isSafMode
? await _buildRelativeOutputDir( ? await _buildRelativeOutputDir(
@@ -3684,13 +3895,101 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
// Fallback: Use SongLink to convert Spotify ID to Deezer ID // For tidal:/qobuz: tracks without ISRC, resolve ISRC from provider
// API directly (faster than SongLink and avoids rate limits).
if (deezerTrackId == null &&
(trackToDownload.isrc == null ||
trackToDownload.isrc!.isEmpty ||
!_isValidISRC(trackToDownload.isrc!)) &&
(trackToDownload.id.startsWith('tidal:') ||
trackToDownload.id.startsWith('qobuz:'))) {
try {
final colonIdx = trackToDownload.id.indexOf(':');
final provider = trackToDownload.id.substring(0, colonIdx);
final providerTrackId = trackToDownload.id.substring(colonIdx + 1);
_log.d('No ISRC, fetching from $provider API: $providerTrackId');
final providerData = provider == 'tidal'
? await PlatformBridge.getTidalMetadata('track', providerTrackId)
: await PlatformBridge.getQobuzMetadata('track', providerTrackId);
final trackData = providerData['track'] as Map<String, dynamic>?;
if (trackData != null) {
final resolvedIsrc = normalizeOptionalString(
trackData['isrc'] as String?,
);
if (resolvedIsrc != null && _isValidISRC(resolvedIsrc)) {
_log.d('Resolved ISRC from $provider: $resolvedIsrc');
final provReleaseDate = normalizeOptionalString(
trackData['release_date'] as String?,
);
final provTrackNum = trackData['track_number'] as int?;
final provDiscNum = trackData['disc_number'] as int?;
trackToDownload = Track(
id: trackToDownload.id,
name: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
artistId: trackToDownload.artistId,
albumId: trackToDownload.albumId,
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
duration: trackToDownload.duration,
isrc: resolvedIsrc,
trackNumber:
(trackToDownload.trackNumber != null &&
trackToDownload.trackNumber! > 0)
? trackToDownload.trackNumber
: provTrackNum,
discNumber:
(trackToDownload.discNumber != null &&
trackToDownload.discNumber! > 0)
? trackToDownload.discNumber
: provDiscNum,
releaseDate: trackToDownload.releaseDate ?? provReleaseDate,
deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
totalTracks: trackToDownload.totalTracks,
source: trackToDownload.source,
);
try {
final deezerResult = await PlatformBridge.searchDeezerByISRC(
resolvedIsrc,
);
if (deezerResult['success'] == true &&
deezerResult['track_id'] != null) {
deezerTrackId = deezerResult['track_id'].toString();
_log.d(
'Found Deezer track ID via $provider ISRC: $deezerTrackId',
);
}
} catch (e) {
_log.w('Failed to search Deezer by $provider ISRC: $e');
}
}
}
} catch (e) {
_log.w('Failed to resolve ISRC from provider: $e');
}
if (shouldAbortWork('during provider ISRC resolution')) {
return;
}
}
if (!selectedExtensionDownloadProvider && if (!selectedExtensionDownloadProvider &&
deezerTrackId == null && deezerTrackId == null &&
!shouldSkipExtensionSongLinkPrelookup && !shouldSkipExtensionSongLinkPrelookup &&
trackToDownload.id.isNotEmpty && trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') && !trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) { !trackToDownload.id.startsWith('extension:') &&
!trackToDownload.id.startsWith('tidal:') &&
!trackToDownload.id.startsWith('qobuz:')) {
try { try {
String spotifyId = trackToDownload.id; String spotifyId = trackToDownload.id;
if (spotifyId.startsWith('spotify:track:')) { if (spotifyId.startsWith('spotify:track:')) {
@@ -3703,7 +4002,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'track', 'track',
spotifyId, spotifyId,
); );
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
final trackData = deezerData['track']; final trackData = deezerData['track'];
if (trackData is Map<String, dynamic>) { if (trackData is Map<String, dynamic>) {
final rawId = trackData['spotify_id'] as String?; final rawId = trackData['spotify_id'] as String?;
@@ -3839,14 +4137,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final relativeDir = useSaf ? outputDir : ''; final relativeDir = useSaf ? outputDir : '';
final fileName = useSaf ? (safFileName ?? '') : ''; final fileName = useSaf ? (safFileName ?? '') : '';
final outputExt = useSaf ? safOutputExt : ''; final outputExt = useSaf ? safOutputExt : '';
final isYouTube = item.service == 'youtube'; final shouldUseExtensions = useExtensions;
final shouldUseExtensions = !isYouTube && useExtensions; final shouldUseFallback = state.autoFallback;
final shouldUseFallback = !isYouTube && state.autoFallback;
if (isYouTube) { if (shouldUseExtensions) {
_log.d('Using YouTube/Cobalt provider for download');
_log.d('Quality: $quality (lossy only)');
} else if (shouldUseExtensions) {
_log.d('Using extension providers for download'); _log.d('Using extension providers for download');
_log.d( _log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
@@ -4013,7 +4307,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
finalSafFileName = reportedFileName; finalSafFileName = reportedFileName;
} }
// Check if file already existed (detected via ISRC match in Go backend)
final wasExisting = result['already_exists'] == true; final wasExisting = result['already_exists'] == true;
if (wasExisting) { if (wasExisting) {
_log.i('File already exists in library: $filePath'); _log.i('File already exists in library: $filePath');
@@ -4026,7 +4319,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String actualQuality = quality; String actualQuality = quality;
if (actualBitDepth != null && actualBitDepth > 0) { if (actualBitDepth != null && actualBitDepth > 0) {
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0 final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
? (actualSampleRate / 1000).toStringAsFixed( ? (actualSampleRate / 1000).toStringAsFixed(
actualSampleRate % 1000 == 0 ? 0 : 1, actualSampleRate % 1000 == 0 ? 0 : 1,
@@ -4182,7 +4474,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
if (isM4aFile || shouldForceTidalSafM4aHandling) { if (isM4aFile || shouldForceTidalSafM4aHandling) {
// At this point filePath is guaranteed non-null by the checks above.
final currentFilePath = filePath; final currentFilePath = filePath;
if (isContentUriPath && effectiveSafMode) { if (isContentUriPath && effectiveSafMode) {
@@ -4521,11 +4812,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else if (metadataEmbeddingEnabled && } else if (metadataEmbeddingEnabled &&
isContentUriPath && isContentUriPath &&
effectiveSafMode && effectiveSafMode &&
isFlacFile && !isM4aFile &&
!wasExisting) { !wasExisting) {
final currentFilePath = filePath; final currentFilePath = filePath;
final isOpusFile = filePath.endsWith('.opus');
final isMp3File = filePath.endsWith('.mp3');
final ext = isOpusFile
? '.opus'
: isMp3File
? '.mp3'
: '.flac';
final formatName = isOpusFile
? 'Opus'
: isMp3File
? 'MP3'
: 'FLAC';
_log.d( _log.d(
'SAF FLAC detected, embedding metadata and cover via temp file...', 'SAF $formatName detected, embedding metadata and cover via temp file...',
); );
final tempPath = await _copySafToTemp(currentFilePath); final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath != null) { if (tempPath != null) {
@@ -4545,21 +4848,39 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendLabel = result['label'] as String?; final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?; final backendCopyright = result['copyright'] as String?;
await _embedMetadataAndCover( if (isMp3File) {
tempPath, await _embedMetadataToMp3(
finalTrack, tempPath,
genre: backendGenre ?? genre, finalTrack,
label: backendLabel ?? label, genre: backendGenre ?? genre,
copyright: backendCopyright, label: backendLabel ?? label,
writeExternalLrc: false, copyright: backendCopyright,
); );
} else if (isOpusFile) {
await _embedMetadataToOpus(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataAndCover(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
writeExternalLrc: false,
);
}
final newFileName = '${safBaseName ?? 'track'}.flac'; final newFileName = '${safBaseName ?? 'track'}$ext';
final newUri = await _writeTempToSaf( final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri, treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir, relativeDir: effectiveOutputDir,
fileName: newFileName, fileName: newFileName,
mimeType: _mimeTypeForExt('.flac'), mimeType: _mimeTypeForExt(ext),
srcPath: tempPath, srcPath: tempPath,
); );
@@ -4569,12 +4890,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
filePath = newUri; filePath = newUri;
finalSafFileName = newFileName; finalSafFileName = newFileName;
_log.d('SAF FLAC metadata embedding completed'); _log.d('SAF $formatName metadata embedding completed');
} else { } else {
_log.w('Failed to write metadata-updated FLAC back to SAF'); _log.w(
'Failed to write metadata-updated $formatName back to SAF',
);
} }
} catch (e) { } catch (e) {
_log.w('SAF FLAC metadata embedding failed: $e'); _log.w('SAF $formatName metadata embedding failed: $e');
} finally { } finally {
try { try {
await File(tempPath).delete(); await File(tempPath).delete();
@@ -4619,109 +4942,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
if (metadataEmbeddingEnabled &&
!wasExisting &&
item.service == 'youtube' &&
filePath != null) {
final isOpusFile = filePath.endsWith('.opus');
final isMp3File = filePath.endsWith('.mp3');
if (isOpusFile || isMp3File) {
_log.i(
'YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file',
);
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final isContentUriPath = isContentUri(filePath);
if (isContentUriPath && effectiveSafMode) {
final tempPath = await _copySafToTemp(filePath);
if (tempPath != null) {
try {
if (isMp3File) {
await _embedMetadataToMp3(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
final ext = isMp3File ? '.mp3' : '.opus';
final newFileName = '${safBaseName ?? 'track'}$ext';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt(ext),
srcPath: tempPath,
);
if (newUri != null) {
if (newUri != filePath) {
await _deleteSafFile(filePath);
}
filePath = newUri;
finalSafFileName = newFileName;
_log.d('YouTube SAF metadata embedding completed');
} else {
_log.w('Failed to write metadata-updated file back to SAF');
}
} catch (e) {
_log.w('YouTube SAF metadata embedding failed: $e');
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
}
}
} else {
try {
if (isMp3File) {
await _embedMetadataToMp3(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('YouTube metadata embedding completed');
} catch (e) {
_log.w('YouTube metadata embedding failed: $e');
}
}
}
}
final itemAfterDownload = _findItemById(item.id); final itemAfterDownload = _findItemById(item.id);
if (itemAfterDownload == null || if (itemAfterDownload == null ||
_isLocallyCancelled(item.id, item: itemAfterDownload)) { _isLocallyCancelled(item.id, item: itemAfterDownload)) {
@@ -4746,9 +4966,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return; return;
} }
// SAF downloads should end with content URI. If we still have a
// transient FD path, recover URI from SAF metadata to keep history
// dedup/exclusion stable.
if (effectiveSafMode && if (effectiveSafMode &&
filePath != null && filePath != null &&
filePath.isNotEmpty && filePath.isNotEmpty &&
@@ -5063,8 +5280,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
_failedInSession++; _failedInSession++;
// Immediately cleanup connections after failure to prevent
// poisoned connection pool from affecting subsequent downloads
try { try {
await PlatformBridge.cleanupConnections(); await PlatformBridge.cleanupConnections();
} catch (e) { } catch (e) {
@@ -5117,7 +5332,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
_failedInSession++; _failedInSession++;
// Immediately cleanup connections after exception
try { try {
await PlatformBridge.cleanupConnections(); await PlatformBridge.cleanupConnections();
} catch (cleanupErr) { } catch (cleanupErr) {
+1 -9
View File
@@ -11,7 +11,7 @@ final _log = AppLogger('ExploreProvider');
class ExploreItem { class ExploreItem {
final String id; final String id;
final String uri; final String uri;
final String type; // track, album, playlist, artist, station final String type;
final String name; final String name;
final String artists; final String artists;
final String? description; final String? description;
@@ -168,7 +168,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
return const ExploreState(); return const ExploreState();
} }
/// Restore cached home feed from SharedPreferences immediately on startup
Future<void> _restoreFromCache() async { Future<void> _restoreFromCache() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -199,7 +198,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
} }
} }
/// Save home feed to SharedPreferences for instant restore on next launch
Future<void> _saveToCache(List<ExploreSection> sections) async { Future<void> _saveToCache(List<ExploreSection> sections) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -212,11 +210,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
} }
} }
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async { Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh && if (!forceRefresh &&
state.hasContent && state.hasContent &&
state.lastFetched != null && state.lastFetched != null &&
@@ -230,7 +226,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
return; return;
} }
// Only show loading spinner if we have no cached content to display
final showLoading = !state.hasContent; final showLoading = !state.hasContent;
state = state.copyWith(isLoading: showLoading, error: null); state = state.copyWith(isLoading: showLoading, error: null);
@@ -247,14 +242,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
if (!extension.enabled || !extension.hasHomeFeed) { if (!extension.enabled || !extension.hasHomeFeed) {
continue; continue;
} }
// If user has a preference, use that
if (preferredId != null && if (preferredId != null &&
preferredId.isNotEmpty && preferredId.isNotEmpty &&
extension.id == preferredId) { extension.id == preferredId) {
targetExt = extension; targetExt = extension;
break; break;
} }
// Otherwise take the first available (fallback to spotify-web if found)
if (targetExt == null || extension.id == 'spotify-web') { if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension; targetExt = extension;
if (preferredId == null && extension.id == 'spotify-web') { if (preferredId == null && extension.id == 'spotify-web') {
@@ -317,7 +310,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
lastFetched: DateTime.now(), lastFetched: DateTime.now(),
); );
// Save to disk cache for instant restore on next app launch
_saveToCache(sections); _saveToCache(sections);
} catch (e, stack) { } catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack); _log.e('Error fetching home feed: $e', e, stack);
+7 -13
View File
@@ -32,14 +32,12 @@ class Extension {
final bool hasMetadataProvider; final bool hasMetadataProvider;
final bool hasDownloadProvider; final bool hasDownloadProvider;
final bool hasLyricsProvider; final bool hasLyricsProvider;
final bool final bool skipMetadataEnrichment;
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final SearchBehavior? searchBehavior; final SearchBehavior? searchBehavior;
final URLHandler? urlHandler; final URLHandler? urlHandler;
final TrackMatching? trackMatching; final TrackMatching? trackMatching;
final PostProcessing? postProcessing; final PostProcessing? postProcessing;
final Map<String, dynamic> final Map<String, dynamic> capabilities;
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
const Extension({ const Extension({
required this.id, required this.id,
@@ -198,12 +196,10 @@ class SearchBehavior {
final String? placeholder; final String? placeholder;
final bool primary; final bool primary;
final String? icon; final String? icon;
final String? final String? thumbnailRatio;
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final int? thumbnailWidth; final int? thumbnailWidth;
final int? thumbnailHeight; final int? thumbnailHeight;
final List<SearchFilter> final List<SearchFilter> filters;
filters; // Available search filters (e.g., track, album, artist, playlist)
const SearchBehavior({ const SearchBehavior({
required this.enabled, required this.enabled,
@@ -239,11 +235,11 @@ class SearchBehavior {
} }
switch (thumbnailRatio) { switch (thumbnailRatio) {
case 'wide': // 16:9 - YouTube style case 'wide':
return (defaultSize * 16 / 9, defaultSize); return (defaultSize * 16 / 9, defaultSize);
case 'portrait': // 2:3 - Poster style case 'portrait':
return (defaultSize * 2 / 3, defaultSize); return (defaultSize * 2 / 3, defaultSize);
case 'square': // 1:1 - Album art style case 'square':
default: default:
return (defaultSize, defaultSize); return (defaultSize, defaultSize);
} }
@@ -290,7 +286,6 @@ class PostProcessing {
} }
} }
/// URL handler configuration for custom URL patterns
class URLHandler { class URLHandler {
final bool enabled; final bool enabled;
final List<String> patterns; final List<String> patterns;
@@ -304,7 +299,6 @@ class URLHandler {
); );
} }
/// Check if a URL matches any of the patterns
bool matchesURL(String url) { bool matchesURL(String url) {
if (!enabled || patterns.isEmpty) return false; if (!enabled || patterns.isEmpty) return false;
final lowerUrl = url.toLowerCase(); final lowerUrl = url.toLowerCase();
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final destPath = p.join(coversDir.path, '$playlistId$ext'); final destPath = p.join(coversDir.path, '$playlistId$ext');
if (playlist.coverImagePath == destPath) return; if (playlist.coverImagePath == destPath) return;
// Copy image to persistent location
await File(sourceFilePath).copy(destPath); await File(sourceFilePath).copy(destPath);
final now = DateTime.now(); final now = DateTime.now();
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final playlist = state.playlistById(playlistId); final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.coverImagePath == null) return; if (playlist == null || playlist.coverImagePath == null) return;
// Delete the file if it exists
final path = playlist.coverImagePath; final path = playlist.coverImagePath;
if (path != null) { if (path != null) {
final file = File(path); final file = File(path);
+4 -28
View File
@@ -252,8 +252,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling(); _startProgressPolling();
// On iOS, start accessing the security-scoped bookmark so the Go backend
// can read files outside the app sandbox.
String? resolvedPath; String? resolvedPath;
bool didStartSecurityAccess = false; bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
@@ -275,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final isSaf = effectiveFolderPath.startsWith('content://'); final isSaf = effectiveFolderPath.startsWith('content://');
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
// been flushed to SQLite yet.
final downloadedPaths = await _historyDb.getAllFilePaths(); final downloadedPaths = await _historyDb.getAllFilePaths();
final inMemoryHistoryPaths = ref final inMemoryHistoryPaths = ref
.read(downloadHistoryProvider) .read(downloadHistoryProvider)
@@ -298,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
if (forceFullScan) { if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf final results = isSaf
? await PlatformBridge.scanSafTree(effectiveFolderPath) ? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath); : await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
@@ -324,16 +318,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Skipped $skippedDownloads files already in download history'); _log.i('Skipped $skippedDownloads files already in download history');
} }
// Full scan should replace library index entirely. await _db.replaceAll(items.map((e) => e.toJson()).toList());
await _db.clearAll(); final persistedItems = [...items]..sort(_compareLibraryItems);
if (items.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
}
final persistedItems =
(await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now(); final now = DateTime.now();
try { try {
@@ -364,7 +350,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
errorCount: state.scanErrorCount, errorCount: state.scanErrorCount,
); );
} else { } else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes(); final existingFiles = await _db.getFileModTimes();
_log.i( _log.i(
'Incremental scan: ${existingFiles.length} existing files in database', 'Incremental scan: ${existingFiles.length} existing files in database',
@@ -423,7 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return; return;
} }
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList = final scannedList =
(result['files'] as List<dynamic>?) ?? (result['files'] as List<dynamic>?) ??
(result['scanned'] as List<dynamic>?) ?? (result['scanned'] as List<dynamic>?) ??
@@ -444,10 +428,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
); );
// Build the incremental merge base from SQLite, not the current
// provider state. Startup auto-scan can fire before `state.items` has
// finished loading, which would otherwise drop unchanged rows from the
// in-memory library until a manual full rescan.
final existingJson = await _db.getAll(); final existingJson = await _db.getAll();
final currentByPath = <String, LocalLibraryItem>{ final currentByPath = <String, LocalLibraryItem>{
for (final item in existingJson.map(LocalLibraryItem.fromJson)) for (final item in existingJson.map(LocalLibraryItem.fromJson))
@@ -468,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
} }
// Upsert new/modified items (excluding downloaded files)
final updatedItems = <LocalLibraryItem>[]; final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = existingDownloadedPaths.length; int skippedDownloads = existingDownloadedPaths.length;
if (scannedList.isNotEmpty) { if (scannedList.isNotEmpty) {
@@ -502,11 +481,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Deleted $deleteCount items from database'); _log.i('Deleted $deleteCount items from database');
} }
final items = final items = currentByPath.values.toList(growable: false)
(await _db.getAll()) ..sort(_compareLibraryItems);
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now(); final now = DateTime.now();
try { try {
+2 -16
View File
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
const _maxRecentItems = 20; const _maxRecentItems = 20;
/// Types of items that can be accessed
enum RecentAccessType { artist, album, track, playlist } enum RecentAccessType { artist, album, track, playlist }
/// Represents a recently accessed item
class RecentAccessItem { class RecentAccessItem {
final String id; final String id;
final String name; final String name;
final String? subtitle; // Artist name for tracks/albums, null for artists final String? subtitle;
final String? imageUrl; final String? imageUrl;
final RecentAccessType type; final RecentAccessType type;
final DateTime accessedAt; final DateTime accessedAt;
final String? providerId; // Extension ID or 'deezer' for built-in final String? providerId;
const RecentAccessItem({ const RecentAccessItem({
required this.id, required this.id,
@@ -53,7 +51,6 @@ class RecentAccessItem {
); );
} }
/// Create a unique key for deduplication
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id'; String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
@override @override
@@ -67,7 +64,6 @@ class RecentAccessItem {
int get hashCode => uniqueKey.hashCode; int get hashCode => uniqueKey.hashCode;
} }
/// State for recent access history
class RecentAccessState { class RecentAccessState {
final List<RecentAccessItem> items; final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds; final Set<String> hiddenDownloadIds;
@@ -92,7 +88,6 @@ class RecentAccessState {
} }
} }
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> { class RecentAccessNotifier extends Notifier<RecentAccessState> {
final AppStateDatabase _appStateDb = AppStateDatabase.instance; final AppStateDatabase _appStateDb = AppStateDatabase.instance;
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
} }
/// Record an access to an artist
void recordArtistAccess({ void recordArtistAccess({
required String id, required String id,
required String name, required String name,
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
); );
} }
/// Record an access to an album
void recordAlbumAccess({ void recordAlbumAccess({
required String id, required String id,
required String name, required String name,
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
); );
} }
/// Record an access to a track
void recordTrackAccess({ void recordTrackAccess({
required String id, required String id,
required String name, required String name,
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
); );
} }
/// Record an access to a playlist
void recordPlaylistAccess({ void recordPlaylistAccess({
required String id, required String id,
required String name, required String name,
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
} }
} }
/// Remove a specific item from history
void removeItem(RecentAccessItem item) { void removeItem(RecentAccessItem item) {
final updatedItems = state.items final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey) .where((e) => e.uniqueKey != item.uniqueKey)
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey)); unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
} }
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) { void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId}; final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden); state = state.copyWith(hiddenDownloadIds: updatedHidden);
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId)); unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
} }
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) { bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId); return state.hiddenDownloadIds.contains(downloadId);
} }
/// Clear all history
void clearHistory() { void clearHistory() {
state = state.copyWith(items: []); state = state.copyWith(items: []);
unawaited(_appStateDb.clearRecentAccessRows()); unawaited(_appStateDb.clearRecentAccessRows());
} }
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() { void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {}); state = state.copyWith(hiddenDownloadIds: {});
unawaited(_appStateDb.clearHiddenRecentDownloadIds()); unawaited(_appStateDb.clearHiddenRecentDownloadIds());
+5 -59
View File
@@ -11,13 +11,11 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings'; const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version'; const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 6; const _currentMigrationVersion = 7;
const _spotifyClientSecretKey = 'spotify_client_secret'; const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider'); final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> { class SettingsNotifier extends Notifier<AppSettings> {
static const List<int> _youtubeOpusSupportedBitrates = [128, 256, 320];
static const List<int> _youtubeMp3SupportedBitrates = [128, 256, 320];
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -40,7 +38,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _runMigrations(prefs); await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded(); await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeSongLinkRegionIfNeeded(); await _normalizeSongLinkRegionIfNeeded();
} }
@@ -122,6 +119,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
); );
} }
state = state.copyWith(lastSeenVersion: AppInfo.version); state = state.copyWith(lastSeenVersion: AppInfo.version);
// Migration 7: YouTube is no longer a built-in service reset to Tidal
if (state.defaultService == 'youtube') {
state = state.copyWith(defaultService: 'tidal');
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings(); await _saveSettings();
} }
@@ -153,49 +154,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
} }
int _nearestSupportedBitrate(int value, List<int> supported) {
var nearest = supported.first;
var nearestDistance = (value - nearest).abs();
for (final option in supported.skip(1)) {
final distance = (value - option).abs();
// On tie, prefer higher quality bitrate.
if (distance < nearestDistance ||
(distance == nearestDistance && option > nearest)) {
nearest = option;
nearestDistance = distance;
}
}
return nearest;
}
int _normalizeYouTubeOpusBitrate(int bitrate) {
return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates);
}
int _normalizeYouTubeMp3Bitrate(int bitrate) {
return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates);
}
Future<void> _normalizeYouTubeBitratesIfNeeded() async {
final normalizedOpus = _normalizeYouTubeOpusBitrate(
state.youtubeOpusBitrate,
);
final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate);
if (normalizedOpus == state.youtubeOpusBitrate &&
normalizedMp3 == state.youtubeMp3Bitrate) {
return;
}
state = state.copyWith(
youtubeOpusBitrate: normalizedOpus,
youtubeMp3Bitrate: normalizedMp3,
);
await _saveSettings();
}
Future<void> _normalizeIosDownloadDirectoryIfNeeded() async { Future<void> _normalizeIosDownloadDirectoryIfNeeded() async {
if (!Platform.isIOS) return; if (!Platform.isIOS) return;
@@ -469,18 +427,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setYoutubeOpusBitrate(int bitrate) {
final normalized = _normalizeYouTubeOpusBitrate(bitrate);
state = state.copyWith(youtubeOpusBitrate: normalized);
_saveSettings();
}
void setYoutubeMp3Bitrate(int bitrate) {
final normalized = _normalizeYouTubeMp3Bitrate(bitrate);
state = state.copyWith(youtubeMp3Bitrate: normalized);
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) { void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled); state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings(); _saveSettings();
+77 -34
View File
@@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url';
int compareVersions(String v1, String v2) { int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.'); final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.'); final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
for (var i = 0; i < maxLen; i++) { for (var i = 0; i < maxLen; i++) {
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0; final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0; final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
if (n1 < n2) return -1; if (n1 < n2) return -1;
if (n1 > n2) return 1; if (n1 > n2) return 1;
} }
@@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) {
} }
class StoreCategory { class StoreCategory {
static const String metadata = 'metadata'; static const String metadata = 'metadata';
static const String download = 'download'; static const String download = 'download';
static const String utility = 'utility'; static const String utility = 'utility';
static const String lyrics = 'lyrics'; static const String lyrics = 'lyrics';
static const String integration = 'integration'; static const String integration = 'integration';
static const List<String> all = [metadata, download, utility, lyrics, integration]; static const List<String> all = [
metadata,
download,
utility,
lyrics,
integration,
];
static String getDisplayName(String category) { static String getDisplayName(String category) {
switch (category) { switch (category) {
@@ -94,7 +99,8 @@ class StoreExtension {
return StoreExtension( return StoreExtension(
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '', name: json['name'] as String? ?? '',
displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', displayName:
json['display_name'] as String? ?? json['name'] as String? ?? '',
version: json['version'] as String? ?? '0.0.0', version: json['version'] as String? ?? '0.0.0',
author: json['author'] as String? ?? 'Unknown', author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
@@ -117,7 +123,6 @@ class StoreExtension {
} }
} }
class StoreState { class StoreState {
final List<StoreExtension> extensions; final List<StoreExtension> extensions;
final String? selectedCategory; final String? selectedCategory;
@@ -160,11 +165,15 @@ class StoreState {
}) { }) {
return StoreState( return StoreState(
extensions: extensions ?? this.extensions, extensions: extensions ?? this.extensions,
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory), selectedCategory: clearCategory
? null
: (selectedCategory ?? this.selectedCategory),
searchQuery: searchQuery ?? this.searchQuery, searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
isDownloading: isDownloading ?? this.isDownloading, isDownloading: isDownloading ?? this.isDownloading,
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId), downloadingId: clearDownloadingId
? null
: (downloadingId ?? this.downloadingId),
error: clearError ? null : (error ?? this.error), error: clearError ? null : (error ?? this.error),
isInitialized: isInitialized ?? this.isInitialized, isInitialized: isInitialized ?? this.isInitialized,
registryUrl: registryUrl ?? this.registryUrl, registryUrl: registryUrl ?? this.registryUrl,
@@ -180,13 +189,16 @@ class StoreState {
if (searchQuery.isNotEmpty) { if (searchQuery.isNotEmpty) {
final query = searchQuery.toLowerCase(); final query = searchQuery.toLowerCase();
result = result.where((e) => result = result
e.name.toLowerCase().contains(query) || .where(
e.displayName.toLowerCase().contains(query) || (e) =>
e.description.toLowerCase().contains(query) || e.name.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) || e.displayName.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)) e.description.toLowerCase().contains(query) ||
).toList(); e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)),
)
.toList();
} }
return result; return result;
@@ -206,23 +218,28 @@ class StoreNotifier extends Notifier<StoreState> {
Future<void> initialize(String cacheDir) async { Future<void> initialize(String cacheDir) async {
if (state.isInitialized) return; if (state.isInitialized) return;
state = state.copyWith(isLoading: true, clearError: true); // Load saved registry URL early to avoid UI flash (empty setup screen)
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
state = state.copyWith(
isLoading: true,
clearError: true,
registryUrl: savedUrl,
);
try { try {
await PlatformBridge.initExtensionStore(cacheDir); await PlatformBridge.initExtensionStore(cacheDir);
// Load saved registry URL from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
if (savedUrl.isNotEmpty) { if (savedUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(savedUrl); await PlatformBridge.setStoreRegistryUrl(savedUrl);
state = state.copyWith(registryUrl: savedUrl);
await refresh(); await refresh();
} }
state = state.copyWith(isInitialized: true, isLoading: false); state = state.copyWith(isInitialized: true, isLoading: false);
_log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})'); _log.i(
'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})',
);
} catch (e) { } catch (e) {
_log.e('Failed to initialize store: $e'); _log.e('Failed to initialize store: $e');
state = state.copyWith(isLoading: false, error: e.toString()); state = state.copyWith(isLoading: false, error: e.toString());
@@ -247,13 +264,12 @@ class StoreNotifier extends Notifier<StoreState> {
// Read back the resolved URL (may differ from input after normalisation). // Read back the resolved URL (may differ from input after normalisation).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl(); final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
// Persist to SharedPreferences
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_registryUrlPrefKey, resolvedUrl); await prefs.setString(_registryUrlPrefKey, resolvedUrl);
state = state.copyWith( state = state.copyWith(
registryUrl: resolvedUrl, registryUrl: resolvedUrl,
extensions: const [], // Clear old extensions extensions: const [],
); );
_log.i('Registry URL set to: $resolvedUrl'); _log.i('Registry URL set to: $resolvedUrl');
@@ -292,7 +308,9 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(isLoading: true, clearError: true); state = state.copyWith(isLoading: true, clearError: true);
try { try {
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh); final extensions = await PlatformBridge.getStoreExtensions(
forceRefresh: forceRefresh,
);
state = state.copyWith( state = state.copyWith(
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(), extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
isLoading: false, isLoading: false,
@@ -320,12 +338,23 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: '', clearCategory: true); state = state.copyWith(searchQuery: '', clearCategory: true);
} }
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async { Future<bool> installExtension(
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); String extensionId,
String tempDir,
String extensionsDir,
) async {
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
try { try {
_log.i('Downloading extension: $extensionId'); _log.i('Downloading extension: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); final downloadPath = await PlatformBridge.downloadStoreExtension(
extensionId,
tempDir,
);
_log.i('Installing extension from: $downloadPath'); _log.i('Installing extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier); final extNotifier = ref.read(extensionProvider.notifier);
@@ -340,18 +369,28 @@ class StoreNotifier extends Notifier<StoreState> {
return success; return success;
} catch (e) { } catch (e) {
_log.e('Failed to install extension: $e'); _log.e('Failed to install extension: $e');
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); state = state.copyWith(
isDownloading: false,
clearDownloadingId: true,
error: e.toString(),
);
return false; return false;
} }
} }
Future<bool> updateExtension(String extensionId, String tempDir) async { Future<bool> updateExtension(String extensionId, String tempDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
try { try {
_log.i('Downloading update for: $extensionId'); _log.i('Downloading update for: $extensionId');
final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); final downloadPath = await PlatformBridge.downloadStoreExtension(
extensionId,
tempDir,
);
_log.i('Upgrading extension from: $downloadPath'); _log.i('Upgrading extension from: $downloadPath');
final extNotifier = ref.read(extensionProvider.notifier); final extNotifier = ref.read(extensionProvider.notifier);
@@ -366,7 +405,11 @@ class StoreNotifier extends Notifier<StoreState> {
return success; return success;
} catch (e) { } catch (e) {
_log.e('Failed to update extension: $e'); _log.e('Failed to update extension: $e');
state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); state = state.copyWith(
isDownloading: false,
clearDownloadingId: true,
error: e.toString(),
);
return false; return false;
} }
} }
-2
View File
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await _saveToStorage(); await _saveToStorage();
} }
/// Set custom seed color (used when dynamic color is disabled)
Future<void> setSeedColor(Color color) async { Future<void> setSeedColor(Color color) async {
state = state.copyWith(seedColorValue: color.toARGB32()); state = state.copyWith(seedColorValue: color.toARGB32());
await _saveToStorage(); await _saveToStorage();
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
); );
} }
} }
+19 -35
View File
@@ -18,21 +18,18 @@ class TrackState {
final String? artistId; final String? artistId;
final String? artistName; final String? artistName;
final String? coverUrl; final String? coverUrl;
final String? headerImageUrl; // Artist header image for background final String? headerImageUrl;
final int? monthlyListeners; final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums; // For artist page final List<ArtistAlbum>? artistAlbums;
final List<Track>? artistTopTracks; // Artist's popular tracks final List<Track>? artistTopTracks;
final List<SearchArtist>? searchArtists; // For search results final List<SearchArtist>? searchArtists;
final List<SearchAlbum>? searchAlbums; // For search results (albums) final List<SearchAlbum>? searchAlbums;
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists) final List<SearchPlaylist>? searchPlaylists;
final bool hasSearchText; // For back button handling final bool hasSearchText;
final bool isShowingRecentAccess; // For recent access mode final bool isShowingRecentAccess;
final String? final String? searchExtensionId;
searchExtensionId; // Extension ID used for current search results final String? selectedSearchFilter;
final String? final String? searchSource;
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
final String?
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
const TrackState({ const TrackState({
this.tracks = const [], this.tracks = const [],
@@ -127,9 +124,9 @@ class ArtistAlbum {
final String releaseDate; final String releaseDate;
final int totalTracks; final int totalTracks;
final String? coverUrl; final String? coverUrl;
final String albumType; // album, single, compilation final String albumType;
final String artists; final String artists;
final String? providerId; // Extension ID if from extension final String? providerId;
const ArtistAlbum({ const ArtistAlbum({
required this.id, required this.id,
@@ -204,7 +201,6 @@ class TrackNotifier extends Notifier<TrackState> {
return const TrackState(); return const TrackState();
} }
/// Check if request is still valid (not cancelled by newer request)
bool _isRequestValid(int requestId) => requestId == _currentRequestId; bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async { Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
@@ -217,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
if (extensionHandler != null) { if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url'); _log.i('Found extension URL handler: $extensionHandler for URL: $url');
// Retry logic for extension URL handlers (up to 3 attempts)
Map<String, dynamic>? result; Map<String, dynamic>? result;
for (int attempt = 1; attempt <= 3; attempt++) { for (int attempt = 1; attempt <= 3; attempt++) {
result = await PlatformBridge.handleURLWithExtension(url); result = await PlatformBridge.handleURLWithExtension(url);
@@ -541,7 +536,6 @@ class TrackNotifier extends Notifier<TrackState> {
return; return;
} }
// If URL doesn't match any known service, it's unrecognized
final isSpotifyUrl = final isSpotifyUrl =
url.contains('open.spotify.com') || url.contains('open.spotify.com') ||
url.contains('spotify.link') || url.contains('spotify.link') ||
@@ -643,7 +637,6 @@ class TrackNotifier extends Notifier<TrackState> {
}) async { }) async {
final requestId = ++_currentRequestId; final requestId = ++_currentRequestId;
// Preserve selected filter during loading
final currentFilter = filterOverride ?? state.selectedSearchFilter; final currentFilter = filterOverride ?? state.selectedSearchFilter;
state = TrackState( state = TrackState(
@@ -662,7 +655,6 @@ class TrackNotifier extends Notifier<TrackState> {
final includeExtensions = final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions; settings.useExtensionProviders && hasActiveMetadataExtensions;
// Determine the effective search provider
final effectiveProvider = builtInSearchProvider ?? 'deezer'; final effectiveProvider = builtInSearchProvider ?? 'deezer';
_log.i( _log.i(
@@ -672,7 +664,6 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> results; Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = []; List<Map<String, dynamic>> metadataTrackResults = [];
// Only use metadata providers for Deezer search (default behavior)
if (effectiveProvider == 'deezer') { if (effectiveProvider == 'deezer') {
try { try {
_log.d('Calling metadata provider search API...'); _log.d('Calling metadata provider search API...');
@@ -692,7 +683,6 @@ class TrackNotifier extends Notifier<TrackState> {
} }
} }
// Call the appropriate search API
switch (effectiveProvider) { switch (effectiveProvider) {
case 'tidal': case 'tidal':
_log.d('Calling Tidal search API...'); _log.d('Calling Tidal search API...');
@@ -808,9 +798,8 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter, // Preserve filter in results selectedSearchFilter: currentFilter,
searchSource: searchSource: effectiveProvider,
effectiveProvider, // Track which service was used for search
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
@@ -837,7 +826,7 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: selectedSearchFilter:
state.selectedSearchFilter, // Preserve filter during loading state.selectedSearchFilter,
); );
try { try {
@@ -876,9 +865,8 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess, isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId, // Store which extension was used searchExtensionId: extensionId,
selectedSearchFilter: selectedSearchFilter: state.selectedSearchFilter,
state.selectedSearchFilter, // Preserve selected filter
); );
} catch (e, stackTrace) { } catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
@@ -934,7 +922,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack; tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks); state = state.copyWith(tracks: tracks);
} catch (_) { } catch (_) {
// Silently ignore update failures - track may have been removed
} }
} }
@@ -942,7 +929,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = const TrackState(); state = const TrackState();
} }
/// Set selected search filter for extension search
void setSearchFilter(String? filter) { void setSearchFilter(String? filter) {
if (state.selectedSearchFilter == filter) return; if (state.selectedSearchFilter == filter) return;
state = state.copyWith( state = state.copyWith(
@@ -951,7 +937,6 @@ class TrackNotifier extends Notifier<TrackState> {
); );
} }
/// Set search text state for back button handling
void setSearchText(bool hasText) { void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) { if (state.hasSearchText == hasText) {
return; return;
@@ -966,7 +951,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = state.copyWith(isShowingRecentAccess: showing); state = state.copyWith(isShowingRecentAccess: showing);
} }
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({ void setTracksFromCollection({
required List<Track> tracks, required List<Track> tracks,
String? albumName, String? albumName,
@@ -1127,7 +1111,7 @@ class TrackNotifier extends Notifier<TrackState> {
'isrc': isrc, 'isrc': isrc,
'track_name': track.name, 'track_name': track.name,
'artist_name': track.artistName, 'artist_name': track.artistName,
'spotify_id': track.id, // Include Spotify ID for Amazon lookup 'spotify_id': track.id,
'service': 'tidal', 'service': 'tidal',
}); });
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) { if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
+21 -6
View File
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart';
@@ -241,6 +242,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
); );
} }
String? _recommendedDownloadService() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.albumId.startsWith('tidal:')) return 'tidal';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('deezer:')) return 'deezer';
return null;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -257,8 +268,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (_isLoading) if (_isLoading)
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()), child: AlbumTrackListSkeleton(itemCount: 10),
), ),
), ),
if (_error != null) if (_error != null)
@@ -534,9 +545,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final track = tracks[index]; final track = tracks[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: _AlbumTrackItem( child: StaggeredListItem(
track: track, index: index,
onDownload: () => _downloadTrack(context, track), child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
), ),
); );
}, childCount: tracks.length), }, childCount: tracks.length),
@@ -551,6 +565,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
trackName: track.name, trackName: track.name,
artistName: track.artistName, artistName: track.artistName,
coverUrl: track.coverUrl, coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) { onSelect: (quality, service) {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
@@ -576,7 +591,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final tracks = _tracks; final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return; if (tracks == null || tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final localLibState = final localLibState =
@@ -623,6 +637,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
context, context,
trackName: '${tracksToQueue.length} tracks', trackName: '${tracksToQueue.length} tracks',
artistName: widget.albumName, artistName: widget.albumName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) { onSelect: (quality, service) {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
+28 -38
View File
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen; show ExtensionAlbumScreen;
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart';
class _ArtistCache { class _ArtistCache {
@@ -152,6 +153,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return tileSize + 64 + ((textScale - 1) * 14); return tileSize + 64 + ((textScale - 1) * 14);
} }
String? _recommendedDownloadService() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.artistId.startsWith('tidal:')) return 'tidal';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('deezer:')) return 'deezer';
return null;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -481,12 +492,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
hasDiscography: hasDiscography, hasDiscography: hasDiscography,
), ),
if (_isLoadingDiscography) if (_isLoadingDiscography)
const SliverToBoxAdapter( const SliverToBoxAdapter(child: ArtistScreenSkeleton()),
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
if (_error != null) if (_error != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -889,6 +895,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (settings.askQualityBeforeDownload) { if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show( DownloadServicePicker.show(
context, context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) { onSelect: (quality, service) {
_fetchAndQueueAlbums(albums, service, quality); _fetchAndQueueAlbums(albums, service, quality);
}, },
@@ -948,7 +955,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
fetchedCount++; fetchedCount++;
// Update progress dialog
if (mounted) { if (mounted) {
_FetchingProgressDialog.updateProgress( _FetchingProgressDialog.updateProgress(
context, context,
@@ -979,7 +985,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return; return;
} }
// Check which tracks are already downloaded
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final tracksToQueue = <Track>[]; final tracksToQueue = <Track>[];
int skippedCount = 0; int skippedCount = 0;
@@ -1030,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
content: Text(message), content: Text(message),
action: SnackBarAction( action: SnackBarAction(
label: context.l10n.snackbarViewQueue, label: context.l10n.snackbarViewQueue,
onPressed: () { onPressed: () {},
// Navigate to queue tab (index 1)
// This will be handled by the navigation system
},
), ),
), ),
); );
@@ -1154,6 +1156,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
imageUrl.isNotEmpty && imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true; Uri.tryParse(imageUrl)?.hasAuthority == true;
final isDark = Theme.of(context).brightness == Brightness.dark;
String? listenersText; String? listenersText;
final listeners = _monthlyListeners ?? widget.monthlyListeners; final listeners = _monthlyListeners ?? widget.monthlyListeners;
if (listeners != null && listeners > 0) { if (listeners != null && listeners > 0) {
@@ -1224,7 +1228,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Colors.transparent, Colors.transparent,
Colors.black.withValues(alpha: 0.3), Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.7), Colors.black.withValues(alpha: 0.7),
colorScheme.surface, isDark
? colorScheme.surface
: Colors.black.withValues(alpha: 0.85),
], ],
stops: const [0.0, 0.5, 0.75, 1.0], stops: const [0.0, 0.5, 0.75, 1.0],
), ),
@@ -1265,7 +1271,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
listenersText, listenersText,
style: Theme.of(context).textTheme.bodyMedium style: Theme.of(context).textTheme.bodyMedium
?.copyWith( ?.copyWith(
color: Colors.white.withValues(alpha: 0.8), color: Colors.white,
shadows: [ shadows: [
Shadow( Shadow(
offset: const Offset(0, 1), offset: const Offset(0, 1),
@@ -1689,6 +1695,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (settings.askQualityBeforeDownload) { if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show( DownloadServicePicker.show(
context, context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) { onSelect: (quality, service) {
if (!mounted) return; if (!mounted) return;
enqueue(service, quality: quality); enqueue(service, quality: quality);
@@ -1839,29 +1846,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Positioned( Positioned(
top: 8, top: 8,
right: 8, right: 8,
child: AnimatedContainer( child: AnimatedSelectionCheckbox(
duration: const Duration(milliseconds: 200), visible: true,
width: 28, selected: isSelected,
height: 28, colorScheme: colorScheme,
decoration: BoxDecoration( size: 28,
color: isSelected unselectedColor: colorScheme.surface.withValues(
? colorScheme.primary alpha: 0.9,
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
), ),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 18,
)
: null,
), ),
), ),
if (showTypeBadge) if (showTypeBadge)
@@ -2070,7 +2062,6 @@ class _FetchingProgressDialog extends StatefulWidget {
required this.onCancel, required this.onCancel,
}); });
// Static method to update progress from outside
static void updateProgress(BuildContext context, int current, int total) { static void updateProgress(BuildContext context, int current, int total) {
final state = context final state = context
.findAncestorStateOfType<_FetchingProgressDialogState>(); .findAncestorStateOfType<_FetchingProgressDialogState>();
@@ -2143,7 +2134,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Progress bar
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator( child: LinearProgressIndicator(
+16 -35
View File
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class DownloadedAlbumScreen extends ConsumerStatefulWidget { class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName; final String albumName;
@@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final tracks = final tracks =
allItems.where((item) { allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist = final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty) (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist! ? item.albumArtist!
@@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
return itemKey == _albumLookupKey; return itemKey == _albumLookupKey;
}).toList()..sort((a, b) { }).toList()..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1; final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1; final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc); if (aDisc != bDisc) return aDisc.compareTo(bDisc);
@@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
PageRouteBuilder( slidePageRoute(page: TrackMetadataScreen(item: item)),
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final track = tracks[index]; final track = tracks[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track), child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
); );
}, childCount: tracks.length), }, childCount: tracks.length),
); );
@@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final discNumbers = _getSortedDiscNumbers(tracks); final discNumbers = _getSortedDiscNumbers(tracks);
final List<Widget> children = []; final List<Widget> children = [];
var revealIndex = 0;
for (final discNumber in discNumbers) { for (final discNumber in discNumbers) {
final discTracks = discMap[discNumber]; final discTracks = discMap[discNumber];
@@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children.add( children.add(
KeyedSubtree( KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track), child: StaggeredListItem(
index: revealIndex++,
child: _buildTrackItem(context, colorScheme, track),
),
), ),
); );
} }
@@ -796,28 +795,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_isSelectionMode) ...[ if (_isSelectionMode) ...[
Container( AnimatedSelectionCheckbox(
width: 24, visible: true,
height: 24, selected: isSelected,
decoration: BoxDecoration( colorScheme: colorScheme,
color: isSelected size: 24,
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
], ],
@@ -1123,7 +1105,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? 'Opus' ? 'Opus'
: null; : null;
if (ext == null || ext == targetFormat) continue; if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue; if (isLosslessTarget && !isLosslessSource) continue;
+375 -106
View File
@@ -28,6 +28,7 @@ import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart';
class HomeTab extends ConsumerStatefulWidget { class HomeTab extends ConsumerStatefulWidget {
@@ -83,6 +84,18 @@ class _SearchResultBuckets {
}); });
} }
enum _SearchSortOption {
defaultOrder,
titleAsc,
titleDesc,
artistAsc,
artistDesc,
durationAsc,
durationDesc,
dateAsc,
dateDesc,
}
const _homeHistoryPreviewLimit = 48; const _homeHistoryPreviewLimit = 48;
class _HomeHistoryPreview { class _HomeHistoryPreview {
@@ -244,6 +257,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Map<String, (double, double)>? _thumbnailSizesCache; Map<String, (double, double)>? _thumbnailSizesCache;
List<Track>? _searchBucketsSourceTracks; List<Track>? _searchBucketsSourceTracks;
_SearchResultBuckets? _searchBucketsCache; _SearchResultBuckets? _searchBucketsCache;
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
double _responsiveScale({ double _responsiveScale({
required BuildContext context, required BuildContext context,
@@ -280,13 +294,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
double _exploreCardSize(BuildContext context) { double _exploreCardSize(BuildContext context) {
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08); final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
final textScale = _effectiveTextScale(context); final textScale = _effectiveTextScale(context);
return 120 * scale * (1 + (textScale - 1) * 0.12); return 145 * scale * (1 + (textScale - 1) * 0.12);
} }
double _exploreSectionHeight(BuildContext context) { double _exploreSectionHeight(BuildContext context) {
final cardSize = _exploreCardSize(context); final cardSize = _exploreCardSize(context);
final textScale = _effectiveTextScale(context); final textScale = _effectiveTextScale(context);
return cardSize + 55 + ((textScale - 1) * 12); return cardSize + 58 + ((textScale - 1) * 12);
} }
@override @override
@@ -564,6 +578,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}'; '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
if (_lastSearchQuery == searchKey) return; if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey; _lastSearchQuery = searchKey;
_searchSortOption = _SearchSortOption.defaultOrder;
final isBuiltInProvider = final isBuiltInProvider =
searchProvider != null && searchProvider != null &&
@@ -698,6 +713,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
playlistName: trackState.playlistName!, playlistName: trackState.playlistName!,
coverUrl: trackState.coverUrl, coverUrl: trackState.coverUrl,
tracks: trackState.tracks, tracks: trackState.tracks,
recommendedService:
trackState.searchExtensionId ?? trackState.searchSource,
), ),
), ),
); );
@@ -1281,8 +1298,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
exploreLoading) exploreLoading)
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()), child: TrackListSkeleton(itemCount: 5),
), ),
), ),
@@ -1485,7 +1502,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
if (hasGreeting && index == 0) { if (hasGreeting && index == 0) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text( child: Text(
greeting, greeting,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -1500,7 +1517,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return _buildExploreSection(sections[sectionIndex], colorScheme); return _buildExploreSection(sections[sectionIndex], colorScheme);
} }
return const SizedBox(height: 16); return const SizedBox(height: 24);
}, childCount: totalCount), }, childCount: totalCount),
), ),
]; ];
@@ -1516,7 +1533,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
child: Text( child: Text(
section.title, section.title,
style: Theme.of( style: Theme.of(
@@ -1532,7 +1549,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
itemCount: section.items.length, itemCount: section.items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = section.items[index]; final item = section.items[index];
return _buildExploreItem(item, colorScheme); return StaggeredListItem(
index: index,
staggerDelay: const Duration(milliseconds: 50),
child: _buildExploreItem(item, colorScheme),
);
}, },
), ),
), ),
@@ -1579,7 +1600,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8, isArtist ? cardSize / 2 : 10,
), ),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage( ? CachedNetworkImage(
@@ -1618,8 +1639,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: isArtist ? TextAlign.center : TextAlign.start, textAlign: isArtist ? TextAlign.center : TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
), ),
@@ -1632,7 +1653,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontSize: 11, fontSize: 12,
), ),
), ),
], ],
@@ -2122,7 +2143,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId != null && if (item.providerId != null &&
item.providerId!.isNotEmpty && item.providerId!.isNotEmpty &&
item.providerId != 'deezer' && item.providerId != 'deezer' &&
item.providerId != 'spotify') { item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -2162,7 +2185,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else if (item.providerId != null && } else if (item.providerId != null &&
item.providerId!.isNotEmpty && item.providerId!.isNotEmpty &&
item.providerId != 'deezer' && item.providerId != 'deezer' &&
item.providerId != 'spotify') { item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -2210,7 +2235,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId != null && if (item.providerId != null &&
item.providerId!.isNotEmpty && item.providerId!.isNotEmpty &&
item.providerId != 'deezer' && item.providerId != 'deezer' &&
item.providerId != 'spotify') { item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -2248,14 +2275,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
if (!mounted) return; if (!mounted) return;
final result = await navigator.push( final result = await navigator.push(
PageRouteBuilder( slidePageRoute(page: TrackMetadataScreen(item: item)),
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
); );
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath, item.filePath,
@@ -2393,6 +2413,168 @@ class _HomeTabState extends ConsumerState<HomeTab>
); );
} }
// Search result sorting
String _sortOptionLabel(_SearchSortOption option) {
switch (option) {
case _SearchSortOption.defaultOrder:
return context.l10n.searchSortDefault;
case _SearchSortOption.titleAsc:
return context.l10n.searchSortTitleAZ;
case _SearchSortOption.titleDesc:
return context.l10n.searchSortTitleZA;
case _SearchSortOption.artistAsc:
return context.l10n.searchSortArtistAZ;
case _SearchSortOption.artistDesc:
return context.l10n.searchSortArtistZA;
case _SearchSortOption.durationAsc:
return context.l10n.searchSortDurationShort;
case _SearchSortOption.durationDesc:
return context.l10n.searchSortDurationLong;
case _SearchSortOption.dateAsc:
return context.l10n.searchSortDateOldest;
case _SearchSortOption.dateDesc:
return context.l10n.searchSortDateNewest;
}
}
void _showSortOptions(ColorScheme colorScheme) {
var tempSort = _searchSortOption;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerLow,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => StatefulBuilder(
builder: (ctx, setSheetState) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Text(
context.l10n.searchSortTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
TextButton(
onPressed: () => setSheetState(
() => tempSort = _SearchSortOption.defaultOrder,
),
child: Text(context.l10n.libraryFilterReset),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: _SearchSortOption.values.map((option) {
return FilterChip(
label: Text(_sortOptionLabel(option)),
selected: tempSort == option,
showCheckmark: false,
onSelected: (_) =>
setSheetState(() => tempSort = option),
);
}).toList(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () {
Navigator.pop(ctx);
if (_searchSortOption != tempSort) {
setState(() {
_searchSortOption = tempSort;
});
}
},
child: Text(context.l10n.libraryFilterApply),
),
),
],
),
),
);
},
),
);
}
List<T> _applySortToList<T>(
List<T> items,
String Function(T) getName,
String Function(T) getArtist,
int Function(T) getDuration,
String? Function(T) getDate,
) {
if (_searchSortOption == _SearchSortOption.defaultOrder) return items;
final sorted = List<T>.of(items);
switch (_searchSortOption) {
case _SearchSortOption.defaultOrder:
break;
case _SearchSortOption.titleAsc:
sorted.sort(
(a, b) =>
getName(a).toLowerCase().compareTo(getName(b).toLowerCase()),
);
case _SearchSortOption.titleDesc:
sorted.sort(
(a, b) =>
getName(b).toLowerCase().compareTo(getName(a).toLowerCase()),
);
case _SearchSortOption.artistAsc:
sorted.sort(
(a, b) =>
getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()),
);
case _SearchSortOption.artistDesc:
sorted.sort(
(a, b) =>
getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()),
);
case _SearchSortOption.durationAsc:
sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b)));
case _SearchSortOption.durationDesc:
sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a)));
case _SearchSortOption.dateAsc:
sorted.sort((a, b) {
final da = getDate(a) ?? '';
final db = getDate(b) ?? '';
return da.compareTo(db);
});
case _SearchSortOption.dateDesc:
sorted.sort((a, b) {
final da = getDate(a) ?? '';
final db = getDate(b) ?? '';
return db.compareTo(da);
});
}
return sorted;
}
List<Widget> _buildSearchResults({ List<Widget> _buildSearchResults({
required List<Track> tracks, required List<Track> tracks,
required List<SearchArtist>? searchArtists, required List<SearchArtist>? searchArtists,
@@ -2406,6 +2588,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
required bool showLocalLibraryIndicator, required bool showLocalLibraryIndicator,
required Map<String, (double, double)> thumbnailSizesByExtensionId, required Map<String, (double, double)> thumbnailSizesByExtensionId,
}) { }) {
final hasActualData =
tracks.isNotEmpty ||
(searchArtists != null && searchArtists.isNotEmpty) ||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty);
if (!hasActualData && isLoading) {
return [const SliverToBoxAdapter(child: HomeSearchSkeleton())];
}
if (!hasResults) { if (!hasResults) {
return [const SliverToBoxAdapter(child: SizedBox.shrink())]; return [const SliverToBoxAdapter(child: SizedBox.shrink())];
} }
@@ -2417,6 +2608,59 @@ class _HomeTabState extends ConsumerState<HomeTab>
final playlistItems = buckets.playlistItems; final playlistItems = buckets.playlistItems;
final artistItems = buckets.artistItems; final artistItems = buckets.artistItems;
final sortedArtists = searchArtists != null && searchArtists.isNotEmpty
? _applySortToList<SearchArtist>(
searchArtists,
(a) => a.name,
(a) => a.name,
(a) => 0,
(a) => null,
)
: searchArtists;
final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty
? _applySortToList<SearchAlbum>(
searchAlbums,
(a) => a.name,
(a) => a.artists,
(a) => 0,
(a) => a.releaseDate,
)
: searchAlbums;
final sortedPlaylists =
searchPlaylists != null && searchPlaylists.isNotEmpty
? _applySortToList<SearchPlaylist>(
searchPlaylists,
(p) => p.name,
(p) => p.owner,
(p) => 0,
(p) => null,
)
: searchPlaylists;
List<Track> sortedTracks;
List<int> sortedTrackIndexes;
if (realTracks.isNotEmpty &&
_searchSortOption != _SearchSortOption.defaultOrder) {
final paired = List.generate(
realTracks.length,
(i) => (realTracks[i], realTrackIndexes[i]),
);
final sortedPairs = _applySortToList<(Track, int)>(
paired,
(p) => p.$1.name,
(p) => p.$1.artistName,
(p) => p.$1.duration,
(p) => p.$1.releaseDate,
);
sortedTracks = sortedPairs.map((p) => p.$1).toList();
sortedTrackIndexes = sortedPairs.map((p) => p.$2).toList();
} else {
sortedTracks = realTracks;
sortedTrackIndexes = realTrackIndexes;
}
final slivers = <Widget>[ final slivers = <Widget>[
if (error != null) if (error != null)
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -2434,24 +2678,28 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
]; ];
if (searchArtists != null && searchArtists.isNotEmpty) { bool sortButtonShown = false;
if (sortedArtists != null && sortedArtists.isNotEmpty) {
slivers.addAll( slivers.addAll(
_buildVirtualizedResultSection( _buildVirtualizedResultSection(
title: context.l10n.searchArtists, title: context.l10n.searchArtists,
itemCount: searchArtists.length, itemCount: sortedArtists.length,
colorScheme: colorScheme, colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchArtistItemWidget( itemBuilder: (index, showDivider) => _SearchArtistItemWidget(
key: ValueKey('search-artist-${searchArtists[index].id}'), key: ValueKey('search-artist-${sortedArtists[index].id}'),
artist: searchArtists[index], artist: sortedArtists[index],
showDivider: showDivider, showDivider: showDivider,
onTap: () => _navigateToArtist( onTap: () => _navigateToArtist(
searchArtists[index].id, sortedArtists[index].id,
searchArtists[index].name, sortedArtists[index].name,
searchArtists[index].imageUrl, sortedArtists[index].imageUrl,
), ),
), ),
), ),
); );
sortButtonShown = true;
} }
if (artistItems.isNotEmpty) { if (artistItems.isNotEmpty) {
@@ -2460,6 +2708,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchArtists, title: context.l10n.searchArtists,
itemCount: artistItems.length, itemCount: artistItems.length,
colorScheme: colorScheme, colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget( itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('artist-${artistItems[index].id}'), key: ValueKey('artist-${artistItems[index].id}'),
item: artistItems[index], item: artistItems[index],
@@ -2468,22 +2717,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
), ),
); );
sortButtonShown = true;
} }
if (searchAlbums != null && searchAlbums.isNotEmpty) { if (sortedAlbums != null && sortedAlbums.isNotEmpty) {
slivers.addAll( slivers.addAll(
_buildVirtualizedResultSection( _buildVirtualizedResultSection(
title: context.l10n.searchAlbums, title: context.l10n.searchAlbums,
itemCount: searchAlbums.length, itemCount: sortedAlbums.length,
colorScheme: colorScheme, colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchAlbumItemWidget( itemBuilder: (index, showDivider) => _SearchAlbumItemWidget(
key: ValueKey('search-album-${searchAlbums[index].id}'), key: ValueKey('search-album-${sortedAlbums[index].id}'),
album: searchAlbums[index], album: sortedAlbums[index],
showDivider: showDivider, showDivider: showDivider,
onTap: () => _navigateToSearchAlbum(searchAlbums[index]), onTap: () => _navigateToSearchAlbum(sortedAlbums[index]),
), ),
), ),
); );
sortButtonShown = true;
} }
if (albumItems.isNotEmpty) { if (albumItems.isNotEmpty) {
@@ -2492,6 +2744,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchAlbums, title: context.l10n.searchAlbums,
itemCount: albumItems.length, itemCount: albumItems.length,
colorScheme: colorScheme, colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget( itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('album-${albumItems[index].id}'), key: ValueKey('album-${albumItems[index].id}'),
item: albumItems[index], item: albumItems[index],
@@ -2500,22 +2753,25 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
), ),
); );
sortButtonShown = true;
} }
if (searchPlaylists != null && searchPlaylists.isNotEmpty) { if (sortedPlaylists != null && sortedPlaylists.isNotEmpty) {
slivers.addAll( slivers.addAll(
_buildVirtualizedResultSection( _buildVirtualizedResultSection(
title: context.l10n.searchPlaylists, title: context.l10n.searchPlaylists,
itemCount: searchPlaylists.length, itemCount: sortedPlaylists.length,
colorScheme: colorScheme, colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget( itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget(
key: ValueKey('search-playlist-${searchPlaylists[index].id}'), key: ValueKey('search-playlist-${sortedPlaylists[index].id}'),
playlist: searchPlaylists[index], playlist: sortedPlaylists[index],
showDivider: showDivider, showDivider: showDivider,
onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]), onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]),
), ),
), ),
); );
sortButtonShown = true;
} }
if (playlistItems.isNotEmpty) { if (playlistItems.isNotEmpty) {
@@ -2524,6 +2780,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchPlaylists, title: context.l10n.searchPlaylists,
itemCount: playlistItems.length, itemCount: playlistItems.length,
colorScheme: colorScheme, colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget( itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('playlist-${playlistItems[index].id}'), key: ValueKey('playlist-${playlistItems[index].id}'),
item: playlistItems[index], item: playlistItems[index],
@@ -2532,20 +2789,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
), ),
); );
sortButtonShown = true;
} }
if (realTracks.isNotEmpty) { if (sortedTracks.isNotEmpty) {
slivers.addAll( slivers.addAll(
_buildVirtualizedResultSection( _buildVirtualizedResultSection(
title: context.l10n.searchSongs, title: context.l10n.searchSongs,
itemCount: realTracks.length, itemCount: sortedTracks.length,
colorScheme: colorScheme, colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _TrackItemWithStatus( itemBuilder: (index, showDivider) => _TrackItemWithStatus(
key: ValueKey(realTracks[index].id), key: ValueKey(sortedTracks[index].id),
track: realTracks[index], track: sortedTracks[index],
index: realTrackIndexes[index], index: sortedTrackIndexes[index],
showDivider: showDivider, showDivider: showDivider,
onDownload: () => _downloadTrack(realTrackIndexes[index]), onDownload: () => _downloadTrack(sortedTrackIndexes[index]),
searchExtensionId: searchExtensionId, searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator, showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -2563,6 +2822,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
required int itemCount, required int itemCount,
required ColorScheme colorScheme, required ColorScheme colorScheme,
required Widget Function(int index, bool showDivider) itemBuilder, required Widget Function(int index, bool showDivider) itemBuilder,
bool showSortButton = false,
}) { }) {
final sectionColor = Theme.of(context).brightness == Brightness.dark final sectionColor = Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend( ? Color.alphaBlend(
@@ -2574,12 +2834,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
return [ return [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Text( child: Row(
title, children: [
style: Theme.of(context).textTheme.titleSmall?.copyWith( Expanded(
color: colorScheme.onSurfaceVariant, child: Text(
), title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
if (showSortButton)
SizedBox(
height: 32,
child: TextButton.icon(
onPressed: () => _showSortOptions(colorScheme),
icon: Icon(
Icons.swap_vert,
size: 18,
color: _searchSortOption != _SearchSortOption.defaultOrder
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
label: Text(
_searchSortOption != _SearchSortOption.defaultOrder
? _sortOptionLabel(_searchSortOption)
: context.l10n.libraryFilterSort,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color:
_searchSortOption != _SearchSortOption.defaultOrder
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8),
visualDensity: VisualDensity.compact,
),
),
),
],
), ),
), ),
), ),
@@ -2587,19 +2882,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
delegate: SliverChildBuilderDelegate((context, index) { delegate: SliverChildBuilderDelegate((context, index) {
final isFirst = index == 0; final isFirst = index == 0;
final isLast = index == itemCount - 1; final isLast = index == itemCount - 1;
return Container( return StaggeredListItem(
margin: const EdgeInsets.symmetric(horizontal: 16), index: index,
decoration: BoxDecoration( child: Container(
color: sectionColor, margin: const EdgeInsets.symmetric(horizontal: 16),
borderRadius: BorderRadius.vertical( decoration: BoxDecoration(
top: isFirst ? const Radius.circular(20) : Radius.zero, color: sectionColor,
bottom: isLast ? const Radius.circular(20) : Radius.zero, borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(20) : Radius.zero,
bottom: isLast ? const Radius.circular(20) : Radius.zero,
),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: itemBuilder(index, !isLast),
), ),
),
clipBehavior: Clip.antiAlias,
child: Material(
color: Colors.transparent,
child: itemBuilder(index, !isLast),
), ),
); );
}, childCount: itemCount), }, childCount: itemCount),
@@ -2793,7 +3091,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
if (searchProvider != null && searchProvider.isNotEmpty) { if (searchProvider != null && searchProvider.isNotEmpty) {
// Check built-in providers first
if (searchProvider == 'tidal') { if (searchProvider == 'tidal') {
return 'Search with Tidal...'; return 'Search with Tidal...';
} }
@@ -2835,16 +3132,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(null); _triggerSearchWithFilter(null);
}, },
showCheckmark: false, showCheckmark: false,
selectedColor: colorScheme.primaryContainer,
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(
color: selectedFilter == null
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: selectedFilter == null
? FontWeight.w600
: FontWeight.normal,
),
), ),
), ),
...filters.map((filter) { ...filters.map((filter) {
@@ -2859,24 +3146,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(filter.id); _triggerSearchWithFilter(filter.id);
}, },
showCheckmark: false, showCheckmark: false,
selectedColor: colorScheme.primaryContainer,
backgroundColor: colorScheme.surfaceContainerHighest,
labelStyle: TextStyle(
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
avatar: filter.icon != null avatar: filter.icon != null
? Icon( ? Icon(_getFilterIcon(filter.icon!), size: 18)
_getFilterIcon(filter.icon!),
size: 18,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
)
: null, : null,
), ),
); );
@@ -2913,7 +3184,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (text.isEmpty || text.length < _minLiveSearchChars) return; if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return; if (text.startsWith('http') || text.startsWith('spotify:')) return;
// Reset last search query to force new search
_lastSearchQuery = null; _lastSearchQuery = null;
_performSearch(text, filterOverride: filter); _performSearch(text, filterOverride: filter);
} }
@@ -2931,15 +3201,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
fillColor: colorScheme.surfaceContainerHighest, fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
borderSide: BorderSide( borderSide: BorderSide(color: colorScheme.outlineVariant),
color: colorScheme.outline.withValues(alpha: 0.5),
),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
borderSide: BorderSide( borderSide: BorderSide(color: colorScheme.outlineVariant),
color: colorScheme.outline.withValues(alpha: 0.5),
),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
@@ -2987,6 +3253,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
), ),
onSubmitted: (_) => _onSearchSubmitted(), onSubmitted: (_) => _onSearchSubmitted(),
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
); );
} }
@@ -3035,7 +3304,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
.firstOrNull; .firstOrNull;
} }
// Check if current provider is a built-in provider (tidal/qobuz)
const builtInProviders = {'tidal', 'qobuz'}; const builtInProviders = {'tidal', 'qobuz'};
final isBuiltInProvider = final isBuiltInProvider =
currentProvider != null && builtInProviders.contains(currentProvider); currentProvider != null && builtInProviders.contains(currentProvider);
@@ -3115,7 +3383,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
], ],
), ),
), ),
// Built-in Tidal search option
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'tidal', value: 'tidal',
child: Row( child: Row(
@@ -3143,7 +3410,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
], ],
), ),
), ),
// Built-in Qobuz search option
PopupMenuItem<String>( PopupMenuItem<String>(
value: 'qobuz', value: 'qobuz',
child: Row( child: Row(
@@ -3966,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>)) .map((t) => _parseTrack(t as Map<String, dynamic>))
.toList(); .toList();
// Extract artist info from album response
final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?; final artistName = result['artists'] as String?;
@@ -4024,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
if (_isLoading) { if (_isLoading) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.albumName)), appBar: AppBar(title: Text(widget.albumName)),
body: const Center(child: CircularProgressIndicator()), body: const AlbumTrackListSkeleton(
itemCount: 10,
showCoverHeader: true,
),
); );
} }
@@ -4178,7 +4446,7 @@ class _ExtensionPlaylistScreenState
if (_isLoading) { if (_isLoading) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.playlistName)), appBar: AppBar(title: Text(widget.playlistName)),
body: const Center(child: CircularProgressIndicator()), body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true),
); );
} }
@@ -4208,6 +4476,7 @@ class _ExtensionPlaylistScreenState
playlistName: widget.playlistName, playlistName: widget.playlistName,
coverUrl: widget.coverUrl, coverUrl: widget.coverUrl,
tracks: _tracks!, tracks: _tracks!,
recommendedService: widget.extensionId,
); );
} }
} }
@@ -4349,7 +4618,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
if (_isLoading) { if (_isLoading) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.artistName)), appBar: AppBar(title: Text(widget.artistName)),
body: const Center(child: CircularProgressIndicator()), body: const ArtistScreenSkeleton(),
); );
} }
+4 -40
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
class LibraryPlaylistsScreen extends ConsumerWidget { class LibraryPlaylistsScreen extends ConsumerWidget {
@@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5), color: colorScheme.outlineVariant.withValues(alpha: 0.5),
), ),
_PlaylistOptionTile( BottomSheetOptionTile(
icon: Icons.edit_outlined, icon: Icons.edit_outlined,
title: context.l10n.collectionRenamePlaylist, title: context.l10n.collectionRenamePlaylist,
onTap: () { onTap: () {
@@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
}, },
), ),
_PlaylistOptionTile( BottomSheetOptionTile(
icon: Icons.image_outlined, icon: Icons.image_outlined,
title: context.l10n.collectionPlaylistChangeCover, title: context.l10n.collectionPlaylistChangeCover,
onTap: () { onTap: () {
@@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
}, },
), ),
_PlaylistOptionTile( BottomSheetOptionTile(
icon: Icons.delete_outline, icon: Icons.delete_outline,
iconColor: colorScheme.error, iconColor: colorScheme.error,
title: context.l10n.collectionDeletePlaylist, title: context.l10n.collectionDeletePlaylist,
@@ -543,40 +544,3 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
); );
} }
} }
/// Styled like _OptionTile in track_collection_quick_actions.dart
class _PlaylistOptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;
final String title;
final VoidCallback onTap;
const _PlaylistOptionTile({
required this.icon,
this.iconColor,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? colorScheme.onPrimaryContainer,
size: 20,
),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}
+96 -124
View File
@@ -15,7 +15,9 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class LibraryTracksFolderScreen extends ConsumerStatefulWidget { class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
final LibraryTracksFolderMode mode; final LibraryTracksFolderMode mode;
@@ -272,7 +274,6 @@ class _LibraryTracksFolderScreenState
break; break;
} }
// Stale selection cleanup
if (_isSelectionMode) { if (_isSelectionMode) {
final validKeys = entries.map((e) => e.key).toSet(); final validKeys = entries.map((e) => e.key).toSet();
_selectedKeys.removeWhere((key) => !validKeys.contains(key)); _selectedKeys.removeWhere((key) => !validKeys.contains(key));
@@ -348,20 +349,23 @@ class _LibraryTracksFolderScreenState
final isSelected = _selectedKeys.contains(entry.key); final isSelected = _selectedKeys.contains(entry.key);
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(entry.key), key: ValueKey(entry.key),
child: _CollectionTrackTile( child: StaggeredListItem(
entry: entry, index: index,
mode: widget.mode, child: _CollectionTrackTile(
playlistId: widget.playlistId, entry: entry,
localLibraryState: localState, mode: widget.mode,
folderTracks: folderTracks, playlistId: widget.playlistId,
isSelectionMode: _isSelectionMode, localLibraryState: localState,
isSelected: isSelected, folderTracks: folderTracks,
onTap: _isSelectionMode isSelectionMode: _isSelectionMode,
? () => _toggleSelection(entry.key) isSelected: isSelected,
: null, onTap: _isSelectionMode
onLongPress: _isSelectionMode ? () => _toggleSelection(entry.key)
? null : null,
: () => _enterSelectionMode(entry.key), onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(entry.key),
),
), ),
); );
}, childCount: entries.length), }, childCount: entries.length),
@@ -372,7 +376,6 @@ class _LibraryTracksFolderScreenState
], ],
), ),
// Selection bottom bar
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
@@ -1081,14 +1084,19 @@ class _CollectionTrackTile extends ConsumerWidget {
final track = entry.track; final track = entry.track;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final effectiveCoverUrl = _resolveCoverUrl(track); final effectiveCoverUrl = _resolveCoverUrl(track);
final isInHistory = ref.watch(
// Fine-grained provider watches only this tile rebuilds when its own
// history / local-library entry changes.
final historyItem = ref.watch(
downloadHistoryProvider.select((state) { downloadHistoryProvider.select((state) {
if (state.isDownloaded(track.id)) return true; final byId = state.getBySpotifyId(track.id);
if (byId != null) return byId;
final isrc = track.isrc?.trim(); final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { if (isrc != null && isrc.isNotEmpty) {
return true; final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
} }
return state.findByTrackAndArtist(track.name, track.artistName) != null; return state.findByTrackAndArtist(track.name, track.artistName);
}), }),
); );
final showLocalLibraryIndicator = ref.watch( final showLocalLibraryIndicator = ref.watch(
@@ -1096,17 +1104,26 @@ class _CollectionTrackTile extends ConsumerWidget {
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
), ),
); );
final isInLocalLibrary = showLocalLibraryIndicator final localItem = showLocalLibraryIndicator
? ref.watch( ? ref.watch(
localLibraryProvider.select( localLibraryProvider.select((state) {
(state) => state.existsInLibrary( final isrc = track.isrc?.trim();
isrc: track.isrc, if (isrc != null && isrc.isNotEmpty) {
trackName: track.name, final byIsrc = state.getByIsrc(isrc);
artistName: track.artistName, if (byIsrc != null) return byIsrc;
), }
), return state.findByTrackAndArtist(track.name, track.artistName);
}),
) )
: false; : null;
final isInHistory = historyItem != null;
final isInLocalLibrary = localItem != null;
final heroTag = historyItem != null
? 'cover_${historyItem.id}'
: localItem != null
? 'cover_lib_${localItem.id}'
: null;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1124,43 +1141,51 @@ class _CollectionTrackTile extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (isSelectionMode) ...[ if (isSelectionMode) ...[
Container( AnimatedSelectionCheckbox(
width: 24, visible: true,
height: 24, selected: isSelected,
decoration: BoxDecoration( colorScheme: colorScheme,
color: isSelected size: 24,
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
], ],
ClipRRect( HeroMode(
borderRadius: BorderRadius.circular(8), enabled: heroTag != null,
child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty child: heroTag != null
? _buildTrackCover(context, effectiveCoverUrl, 52) ? Hero(
: Container( tag: heroTag,
width: 52, child: ClipRRect(
height: 52, borderRadius: BorderRadius.circular(8),
color: colorScheme.surfaceContainerHighest, child:
child: Icon( effectiveCoverUrl != null &&
Icons.music_note, effectiveCoverUrl.isNotEmpty
color: colorScheme.onSurfaceVariant, ? _buildTrackCover(context, effectiveCoverUrl, 52)
: Container(
width: 52,
height: 52,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
), ),
)
: ClipRRect(
borderRadius: BorderRadius.circular(8),
child:
effectiveCoverUrl != null &&
effectiveCoverUrl.isNotEmpty
? _buildTrackCover(context, effectiveCoverUrl, 52)
: Container(
width: 52,
height: 52,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
), ),
), ),
], ],
@@ -1390,9 +1415,8 @@ class _CollectionTrackTile extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5), color: colorScheme.outlineVariant.withValues(alpha: 0.5),
), ),
// Add to playlist (hidden in wishlist unless already downloaded)
if (showAddToPlaylist) if (showAddToPlaylist)
_CollectionOptionTile( BottomSheetOptionTile(
icon: Icons.playlist_add, icon: Icons.playlist_add,
title: context.l10n.collectionAddToPlaylist, title: context.l10n.collectionAddToPlaylist,
onTap: () { onTap: () {
@@ -1401,8 +1425,7 @@ class _CollectionTrackTile extends ConsumerWidget {
}, },
), ),
// Remove from folder / playlist BottomSheetOptionTile(
_CollectionOptionTile(
icon: Icons.remove_circle_outline, icon: Icons.remove_circle_outline,
iconColor: colorScheme.error, iconColor: colorScheme.error,
title: mode == LibraryTracksFolderMode.playlist title: mode == LibraryTracksFolderMode.playlist
@@ -1500,16 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget {
); );
if (historyItem != null) { if (historyItem != null) {
await Navigator.of(context).push( await Navigator.of(
PageRouteBuilder( context,
transitionDuration: const Duration(milliseconds: 300), ).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem)));
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: historyItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
return; return;
} }
@@ -1524,16 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget {
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
if (localItem != null) { if (localItem != null) {
await Navigator.of(context).push( await Navigator.of(
PageRouteBuilder( context,
transitionDuration: const Duration(milliseconds: 300), ).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem)));
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(localItem: localItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
return; return;
} }
@@ -1542,43 +1551,6 @@ class _CollectionTrackTile extends ConsumerWidget {
} }
} }
/// Styled like _OptionTile in track_collection_quick_actions.dart
class _CollectionOptionTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;
final String title;
final VoidCallback onTap;
const _CollectionOptionTile({
required this.icon,
this.iconColor,
required this.title,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? colorScheme.onPrimaryContainer,
size: 20,
),
),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}
class _SelectionActionButton extends StatelessWidget { class _SelectionActionButton extends StatelessWidget {
final IconData icon; final IconData icon;
final String label; final String label;
+11 -33
View File
@@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class LocalAlbumScreen extends ConsumerStatefulWidget { class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName; final String albumName;
@@ -531,7 +532,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) return null; if (tracks.isEmpty) return null;
final first = tracks.first; final first = tracks.first;
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) { if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? ''; final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate; final firstBitrate = first.bitrate;
@@ -543,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return '$fmt ${firstBitrate}kbps'.trim(); return '$fmt ${firstBitrate}kbps'.trim();
} }
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null || if (first.bitDepth == null ||
first.bitDepth == 0 || first.bitDepth == 0 ||
first.sampleRate == null) { first.sampleRate == null) {
@@ -630,7 +629,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final track = discTracks[index]; final track = discTracks[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track), child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
); );
}, childCount: discTracks.length), }, childCount: discTracks.length),
), ),
@@ -669,28 +671,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_isSelectionMode) ...[ if (_isSelectionMode) ...[
Container( AnimatedSelectionCheckbox(
width: 24, visible: true,
height: 24, selected: isSelected,
decoration: BoxDecoration( colorScheme: colorScheme,
color: isSelected size: 24,
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
], ],
@@ -1382,7 +1367,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} }
} }
if (currentFormat == null || currentFormat == targetFormat) continue; if (currentFormat == null || currentFormat == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = final isLosslessSource =
currentFormat == 'FLAC' || currentFormat == 'M4A'; currentFormat == 'FLAC' || currentFormat == 'M4A';
@@ -1503,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bitrate: bitrate, bitrate: bitrate,
metadata: metadata, metadata: metadata,
coverPath: coverPath, coverPath: coverPath,
deleteOriginal: !isSaf, // Only delete original for regular files deleteOriginal: !isSaf,
); );
if (coverPath != null) { if (coverPath != null) {
@@ -1522,15 +1506,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} }
if (isSaf) { if (isSaf) {
// For SAF: derive the parent tree URI and relative dir from the content URI,
// then create new SAF file and delete old one
// Parse the SAF URI to get the tree document path:
// content://...tree/...document/.../oldName.flac
// We need tree URI and relative dir to create the new file
final uri = Uri.parse(item.filePath); final uri = Uri.parse(item.filePath);
final pathSegments = uri.pathSegments; final pathSegments = uri.pathSegments;
// Try to find 'tree' and 'document' segments
String? treeUri; String? treeUri;
String relativeDir = ''; String relativeDir = '';
String oldFileName = ''; String oldFileName = '';
+71 -37
View File
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MainShell'); final _log = AppLogger('MainShell');
@@ -31,9 +32,11 @@ class MainShell extends ConsumerStatefulWidget {
ConsumerState<MainShell> createState() => _MainShellState(); ConsumerState<MainShell> createState() => _MainShellState();
} }
class _MainShellState extends ConsumerState<MainShell> { class _MainShellState extends ConsumerState<MainShell>
with SingleTickerProviderStateMixin {
int _currentIndex = 0; int _currentIndex = 0;
late final PageController _pageController; late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false; bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription; StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress; DateTime? _lastBackPress;
@@ -48,6 +51,11 @@ class _MainShellState extends ConsumerState<MainShell> {
void initState() { void initState() {
super.initState(); super.initState();
_pageController = PageController(initialPage: _currentIndex); _pageController = PageController(initialPage: _currentIndex);
_tabJumpTransitionController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
value: 1,
);
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: false, showStoreTab: false,
@@ -154,7 +162,6 @@ class _MainShellState extends ConsumerState<MainShell> {
if (!Platform.isAndroid) return; if (!Platform.isAndroid) return;
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
// Only show if user is still on legacy storage mode with a download dir set
if (settings.storageMode == 'saf') return; if (settings.storageMode == 'saf') return;
if (settings.downloadDirectory.isEmpty) return; if (settings.downloadDirectory.isEmpty) return;
@@ -229,6 +236,7 @@ class _MainShellState extends ConsumerState<MainShell> {
void dispose() { void dispose() {
_shareSubscription?.cancel(); _shareSubscription?.cancel();
_pageController.dispose(); _pageController.dispose();
_tabJumpTransitionController.dispose();
super.dispose(); super.dispose();
} }
@@ -251,7 +259,8 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
if (_currentIndex != index) { if (_currentIndex != index) {
final shouldResetHome = index == 0; final previousIndex = _currentIndex;
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
setState(() => _currentIndex = index); setState(() => _currentIndex = index);
final showStore = ref.read( final showStore = ref.read(
@@ -262,19 +271,23 @@ class _MainShellState extends ConsumerState<MainShell> {
showStoreTab: showStore, showStoreTab: showStore,
); );
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
if (shouldResetHome) { // Jump directly when skipping intermediate tabs to avoid
_resetHomeToMain(); // sliding through them. For those jumps, keep a short fade-in
// so the transition still feels intentional.
if (isNonAdjacentJump) {
_pageController.jumpToPage(index);
_tabJumpTransitionController.forward(from: 0);
} else {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
} }
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
);
} }
} }
void _onPageChanged(int index) { void _onPageChanged(int index) {
final previousIndex = _currentIndex;
if (_currentIndex != index) { if (_currentIndex != index) {
setState(() => _currentIndex = index); setState(() => _currentIndex = index);
final showStore = ref.read( final showStore = ref.read(
@@ -285,9 +298,6 @@ class _MainShellState extends ConsumerState<MainShell> {
showStoreTab: showStore, showStoreTab: showStore,
); );
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
if (index == 0 && previousIndex != 0) {
_resetHomeToMain();
}
} }
} }
@@ -451,32 +461,44 @@ class _MainShellState extends ConsumerState<MainShell> {
label: l10n.navHome, label: l10n.navHome,
), ),
NavigationDestination( NavigationDestination(
icon: Badge( icon: AnimatedBadge(
isLabelVisible: queueState > 0, count: queueState,
label: Text('$queueState'),
child: const Icon(Icons.library_music_outlined),
),
selectedIcon: SlidingIcon(
child: Badge( child: Badge(
isLabelVisible: queueState > 0, isLabelVisible: queueState > 0,
label: Text('$queueState'), label: Text('$queueState'),
child: const Icon(Icons.library_music), child: const Icon(Icons.library_music_outlined),
),
),
selectedIcon: SlidingIcon(
child: AnimatedBadge(
count: queueState,
child: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.library_music),
),
), ),
), ),
label: l10n.navLibrary, label: l10n.navLibrary,
), ),
if (showStore) if (showStore)
NavigationDestination( NavigationDestination(
icon: Badge( icon: AnimatedBadge(
isLabelVisible: storeUpdatesCount > 0, count: storeUpdatesCount,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
selectedIcon: SwingIcon(
child: Badge( child: Badge(
isLabelVisible: storeUpdatesCount > 0, isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'), label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store), child: const Icon(Icons.store_outlined),
),
),
selectedIcon: SwingIcon(
child: AnimatedBadge(
count: storeUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
),
), ),
), ),
label: l10n.navStore, label: l10n.navStore,
@@ -504,15 +526,27 @@ class _MainShellState extends ConsumerState<MainShell> {
return true; return true;
}, },
child: Scaffold( child: Scaffold(
body: PageView.builder( body: AnimatedBuilder(
controller: _pageController, animation: _tabJumpTransitionController,
itemCount: tabs.length, child: PageView.builder(
onPageChanged: _onPageChanged, controller: _pageController,
physics: const NeverScrollableScrollPhysics(), itemCount: tabs.length,
itemBuilder: (context, index) => _KeepAliveTabPage( onPageChanged: _onPageChanged,
key: ValueKey('page-$index'), physics: const NeverScrollableScrollPhysics(),
child: tabs[index], itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
),
), ),
builder: (context, child) {
final t = Curves.easeOutCubic.transform(
_tabJumpTransitionController.value,
);
return Opacity(
opacity: t,
child: Transform.scale(scale: 0.985 + (0.015 * t), child: child),
);
},
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex.clamp(0, maxIndex), selectedIndex: _currentIndex.clamp(0, maxIndex),
@@ -707,7 +741,7 @@ class _SwingIconState extends State<SwingIcon>
TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20), TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20),
TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20), TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20),
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20), TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); ]).animate(_controller);
_controller.forward(); _controller.forward();
} }
+38 -7
View File
@@ -15,12 +15,14 @@ import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class PlaylistScreen extends ConsumerStatefulWidget { class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName; final String playlistName;
final String? coverUrl; final String? coverUrl;
final List<Track> tracks; final List<Track> tracks;
final String? playlistId; final String? playlistId;
final String? recommendedService;
const PlaylistScreen({ const PlaylistScreen({
super.key, super.key,
@@ -28,6 +30,7 @@ class PlaylistScreen extends ConsumerStatefulWidget {
this.coverUrl, this.coverUrl,
required this.tracks, required this.tracks,
this.playlistId, this.playlistId,
this.recommendedService,
}); });
@override @override
@@ -47,6 +50,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName; String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl; String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
String? _recommendedDownloadService() {
final explicit = widget.recommendedService;
if (explicit != null && explicit.isNotEmpty) {
return explicit;
}
final playlistId = widget.playlistId;
if (playlistId != null) {
if (playlistId.startsWith('tidal:')) return 'tidal';
if (playlistId.startsWith('qobuz:')) return 'qobuz';
if (playlistId.startsWith('deezer:')) return 'deezer';
}
final source = _tracks.firstOrNull?.source;
if (source != null && source.isNotEmpty) {
return source;
}
final trackId = _tracks.firstOrNull?.id ?? '';
if (trackId.startsWith('tidal:')) return 'tidal';
if (trackId.startsWith('qobuz:')) return 'qobuz';
if (trackId.startsWith('deezer:')) return 'deezer';
return null;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -360,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
if (_isLoading) { if (_isLoading) {
return const SliverToBoxAdapter( return const SliverToBoxAdapter(
child: Padding( child: Padding(
padding: EdgeInsets.all(32), padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()), child: TrackListSkeleton(itemCount: 8),
), ),
); );
} }
@@ -411,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final track = _tracks[index]; final track = _tracks[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(track.id), key: ValueKey(track.id),
child: _PlaylistTrackItem( child: StaggeredListItem(
track: track, index: index,
onDownload: () => _downloadTrack(context, track), child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
), ),
); );
}, childCount: _tracks.length), }, childCount: _tracks.length),
@@ -429,6 +460,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
trackName: track.name, trackName: track.name,
artistName: track.artistName, artistName: track.artistName,
coverUrl: track.coverUrl, coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) { onSelect: (quality, service) {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
@@ -616,7 +648,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _downloadTracks(BuildContext context, List<Track> tracks) { void _downloadTracks(BuildContext context, List<Track> tracks) {
if (tracks.isEmpty) return; if (tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final localLibState = final localLibState =
@@ -663,6 +694,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
context, context,
trackName: '${tracksToQueue.length} tracks', trackName: '${tracksToQueue.length} tracks',
artistName: _playlistName, artistName: _playlistName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) { onSelect: (quality, service) {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
@@ -725,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
}), }),
); );
// Check local library for duplicate detection
final showLocalLibraryIndicator = ref.watch( final showLocalLibraryIndicator = ref.watch(
settingsProvider.select( settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
+924 -1076
View File
File diff suppressed because it is too large Load Diff
+39 -33
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart';
class SearchScreen extends ConsumerStatefulWidget { class SearchScreen extends ConsumerStatefulWidget {
@@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService); .addToQueue(track, settings.defaultService);
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); );
} }
@override @override
@@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
child: Text(error, style: TextStyle(color: colorScheme.error)), child: Text(error, style: TextStyle(color: colorScheme.error)),
), ),
Expanded( Expanded(
child: tracks.isEmpty child: AnimatedStateSwitcher(
? _buildEmptyState(colorScheme) child: isLoading && tracks.isEmpty
: ListView.builder( ? const TrackListSkeleton(key: ValueKey('loading'))
itemCount: tracks.length, : tracks.isEmpty
itemBuilder: (context, index) => ? _buildEmptyState(colorScheme)
_buildTrackTile(tracks[index], colorScheme), : ListView.builder(
), key: const ValueKey('results'),
itemCount: tracks.length,
itemBuilder: (context, index) => StaggeredListItem(
index: index,
child: _buildTrackTile(tracks[index], colorScheme),
),
),
),
), ),
], ],
), ),
@@ -127,32 +135,30 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
} }
Widget _buildTrackTile(Track track, ColorScheme colorScheme) { Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
return ListTile( final coverWidget = track.coverUrl != null
leading: track.coverUrl != null ? ClipRRect(
? ClipRRect( borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(
child: CachedNetworkImage( imageUrl: track.coverUrl!,
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( fit: BoxFit.cover,
color: colorScheme.surfaceContainerHighest, memCacheWidth: 144,
borderRadius: BorderRadius.circular(8), memCacheHeight: 144,
), cacheManager: CoverCacheManager.instance,
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
), ),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
);
return ListTile(
leading: coverWidget,
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
+15 -183
View File
@@ -477,122 +477,40 @@ class _CryptoWalletItem extends StatelessWidget {
} }
} }
int _cr(String v) { class _SupporterChip extends StatelessWidget {
int r = 0x1F;
for (final c in v.codeUnits) {
r = (r * 31 + c) & 0x7FFFFFFF;
}
return r;
}
// Highlighted supporters (hashes of names).
const _cv = <int>{1211573191, 1003219236};
// Diamond tier supporters ($50+ donors).
const _dv = <int>{560908930};
enum _SupporterTier { normal, gold, diamond }
_SupporterTier _tierOf(String name) {
final h = _cr(name);
if (_dv.contains(h)) return _SupporterTier.diamond;
if (_cv.contains(h)) return _SupporterTier.gold;
return _SupporterTier.normal;
}
class _SupporterChip extends StatefulWidget {
final String name; final String name;
final ColorScheme colorScheme; final ColorScheme colorScheme;
const _SupporterChip({required this.name, required this.colorScheme}); const _SupporterChip({required this.name, required this.colorScheme});
@override
State<_SupporterChip> createState() => _SupporterChipState();
}
class _SupporterChipState extends State<_SupporterChip>
with SingleTickerProviderStateMixin {
late final _SupporterTier _tier;
AnimationController? _shimmerController;
@override
void initState() {
super.initState();
_tier = _tierOf(widget.name);
if (_tier == _SupporterTier.diamond) {
_shimmerController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2400),
)..repeat();
}
}
@override
void dispose() {
_shimmerController?.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
if (_tier == _SupporterTier.diamond) {
return _buildDiamondChip(isDark);
}
final isGold = _tier == _SupporterTier.gold;
const goldChipColor = Color(0xFFFFF8DC);
const goldAccentColor = Color(0xFFB8860B);
const goldDarkChipColor = Color(0xFF3A3000);
final chipColor = isGold
? goldChipColor
: widget.colorScheme.secondaryContainer;
final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary;
final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor;
return Material( return Material(
color: effectiveChipColor, color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Container( child: Padding(
decoration: isGold
? BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: accentColor.withValues(alpha: 0.4),
width: 1,
),
)
: null,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
CircleAvatar( CircleAvatar(
radius: 10, radius: 10,
backgroundColor: accentColor.withValues(alpha: 0.2), backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: isGold child: Text(
? Icon(Icons.star_rounded, size: 12, color: accentColor) name.isNotEmpty ? name[0].toUpperCase() : '?',
: Text( style: TextStyle(
widget.name.isNotEmpty fontSize: 10,
? widget.name[0].toUpperCase() fontWeight: FontWeight.bold,
: '?', color: colorScheme.primary,
style: TextStyle( ),
fontSize: 10, ),
fontWeight: FontWeight.bold,
color: accentColor,
),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
widget.name, name,
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: isGold color: colorScheme.onSecondaryContainer,
? accentColor fontWeight: FontWeight.w500,
: widget.colorScheme.onSecondaryContainer,
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
), ),
), ),
], ],
@@ -600,92 +518,6 @@ class _SupporterChipState extends State<_SupporterChip>
), ),
); );
} }
Widget _buildDiamondChip(bool isDark) {
const diamondLight = Color(0xFFE8F4FD);
const diamondDark = Color(0xFF0D2B3E);
const diamondAccent = Color(0xFF4FC3F7);
const diamondHighlight = Color(0xFFB3E5FC);
final chipBg = isDark ? diamondDark : diamondLight;
return AnimatedBuilder(
animation: _shimmerController!,
builder: (context, child) {
final t = _shimmerController!.value;
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
begin: Alignment(-2.0 + 4.0 * t, 0.0),
end: Alignment(-1.0 + 4.0 * t, 0.0),
colors: [
chipBg,
isDark
? diamondAccent.withValues(alpha: 0.18)
: diamondHighlight.withValues(alpha: 0.7),
chipBg,
],
stops: const [0.0, 0.5, 1.0],
),
border: Border.all(
color: diamondAccent.withValues(
alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()),
),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: diamondAccent.withValues(
alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()),
),
blurRadius: 8,
spreadRadius: 0,
),
],
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
diamondAccent.withValues(alpha: 0.3),
diamondAccent.withValues(alpha: 0.15),
],
),
),
child: const Icon(
Icons.diamond_rounded,
size: 12,
color: diamondAccent,
),
),
const SizedBox(width: 8),
Text(
widget.name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: isDark ? diamondHighlight : diamondAccent,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
},
);
}
} }
class _NoticeLine extends StatelessWidget { class _NoticeLine extends StatelessWidget {
+17 -100
View File
@@ -465,34 +465,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
), ),
), ),
], ],
SettingsItem(
title: context.l10n.youtubeOpusBitrateTitle,
subtitle:
'${settings.youtubeOpusBitrate}kbps (128/256/320)',
onTap: () => _showYoutubeBitratePicker(
context: context,
title: context.l10n.youtubeOpusBitrateTitle,
currentValue: settings.youtubeOpusBitrate,
options: const [128, 256, 320],
onSave: (value) => ref
.read(settingsProvider.notifier)
.setYoutubeOpusBitrate(value),
),
),
SettingsItem(
title: context.l10n.youtubeMp3BitrateTitle,
subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)',
onTap: () => _showYoutubeBitratePicker(
context: context,
title: context.l10n.youtubeMp3BitrateTitle,
currentValue: settings.youtubeMp3Bitrate,
options: const [128, 256, 320],
onSave: (value) => ref
.read(settingsProvider.notifier)
.setYoutubeMp3Bitrate(value),
),
showDivider: false,
),
], ],
), ),
), ),
@@ -869,6 +841,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
return 'Albums/[Year] Album/'; return 'Albums/[Year] Album/';
case 'artist_album_singles': case 'artist_album_singles':
return 'Artist/Album/ + Artist/Singles/'; return 'Artist/Album/ + Artist/Singles/';
case 'artist_album_flat':
return 'Artist/Album/ + Artist/song.flac';
default: default:
return 'Albums/Artist/Album Name/'; return 'Albums/Artist/Album Name/';
} }
@@ -958,6 +932,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
ListTile(
leading: const Icon(Icons.person_outline_outlined),
title: Text(context.l10n.albumFolderArtistAlbumFlat),
subtitle: Text(context.l10n.albumFolderArtistAlbumFlatSubtitle),
trailing: current == 'artist_album_flat'
? const Icon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setAlbumFolderStructure('artist_album_flat');
Navigator.pop(context);
},
),
], ],
), ),
), ),
@@ -1689,68 +1677,6 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
); );
} }
void _showYoutubeBitratePicker({
required BuildContext context,
required String title,
required int currentValue,
required List<int> options,
required void Function(int value) onSave,
}) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.fromLTRB(24, 12, 24, 8),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(sheetContext).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
],
),
),
for (final bitrate in options)
ListTile(
title: Text('$bitrate kbps'),
trailing: bitrate == currentValue
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
onSave(bitrate);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
);
}
void _showMusixmatchLanguagePicker( void _showMusixmatchLanguagePicker(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
@@ -2100,7 +2026,7 @@ class _ServiceSelector extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube']; final builtInServiceIds = ['tidal', 'qobuz', 'deezer'];
final extensionProviders = extState.extensions final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider) .where((e) => e.enabled && e.hasDownloadProvider)
@@ -2136,15 +2062,6 @@ class _ServiceSelector extends ConsumerWidget {
onTap: () => onChanged('qobuz'), onTap: () => onChanged('qobuz'),
), ),
), ),
const SizedBox(width: 8),
Expanded(
child: _ServiceChip(
icon: Icons.smart_display,
label: 'YouTube',
isSelected: effectiveService == 'youtube',
onTap: () => onChanged('youtube'),
),
),
], ],
), ),
if (extensionProviders.isNotEmpty) ...[ if (extensionProviders.isNotEmpty) ...[
@@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final hasError = extension.status == 'error'; final hasError = extension.status == 'error';
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
+1 -3
View File
@@ -61,7 +61,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final topPadding = normalizedHeaderTopPadding(context); final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@@ -600,14 +600,12 @@ class _SearchProviderSelector extends ConsumerWidget {
.where((e) => e.enabled && e.hasCustomSearch) .where((e) => e.enabled && e.hasCustomSearch)
.toList(); .toList();
// Always allow tapping: built-in providers are always available
final hasAnyProvider = final hasAnyProvider =
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty; searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
String currentProviderName = context.l10n.extensionDefaultProvider; String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null && if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) { settings.searchProvider!.isNotEmpty) {
// Check built-in first
if (_builtInProviders.containsKey(settings.searchProvider)) { if (_builtInProviders.containsKey(settings.searchProvider)) {
currentProviderName = _builtInProviders[settings.searchProvider]!; currentProviderName = _builtInProviders[settings.searchProvider]!;
} else { } else {
@@ -23,21 +23,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
int _androidSdkVersion = 0; int _androidSdkVersion = 0;
bool _hasStoragePermission = false; bool _hasStoragePermission = false;
/// Convert SAF content URI to a readable display path
String _getDisplayPath(String path) { String _getDisplayPath(String path) {
if (!path.startsWith('content://')) return path; if (!path.startsWith('content://')) return path;
// Extract the path portion from SAF tree URI
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
// -> /storage/emulated/0/Music
try { try {
final uri = Uri.parse(path); final uri = Uri.parse(path);
final treePath = final treePath = uri.pathSegments.last;
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final decoded = Uri.decodeComponent(treePath); final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) { if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}'; return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
} }
// For SD card or other volumes, just show the decoded path
return decoded; return decoded;
} catch (_) { } catch (_) {
return path; return path;
+1 -1
View File
@@ -136,7 +136,7 @@ class _LogScreenState extends State<LogScreen> {
final logs = _filteredLogs; final logs = _filteredLogs;
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController, controller: _scrollController,
@@ -19,7 +19,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final topPadding = normalizedHeaderTopPadding(context); final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true,
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
@@ -340,12 +340,6 @@ class _ProviderItem extends StatelessWidget {
icon: Icons.graphic_eq, icon: Icons.graphic_eq,
isBuiltIn: true, isBuiltIn: true,
); );
case 'youtube':
return _ProviderInfo(
name: 'YouTube',
icon: Icons.play_circle_outline,
isBuiltIn: true,
);
default: default:
return _ProviderInfo( return _ProviderInfo(
name: provider, name: provider,
+2 -21
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/screens/settings/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class SettingsTab extends ConsumerWidget { class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key}); const SettingsTab({super.key});
@@ -150,26 +151,6 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) { void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(slidePageRoute(page: page));
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
var tween = Tween(
begin: begin,
end: end,
).chain(CurveTween(curve: curve));
return SlideTransition(
position: animation.drive(tween),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
),
);
} }
} }
+1 -7
View File
@@ -441,14 +441,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
void _nextPage() { void _nextPage() {
bool canProceed = false; bool canProceed = false;
// Step 0 is Welcome, always can proceed
if (_currentStep == 0) { if (_currentStep == 0) {
canProceed = true; canProceed = true;
} else { } else {
// Logic for other steps (offset by 1 because of welcome step)
// Step 1: Storage
// Step 2: Notification (if android 13+) OR Directory
// etc.
canProceed = _isStepCompleted(_currentStep); canProceed = _isStepCompleted(_currentStep);
} }
@@ -470,9 +465,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} }
bool _isStepCompleted(int step) { bool _isStepCompleted(int step) {
if (step == 0) return true; // Welcome if (step == 0) return true;
// Adjust step index for logic because we added Welcome at 0
final logicStep = step - 1; final logicStep = step - 1;
if (_androidSdkVersion >= 33) { if (_androidSdkVersion >= 33) {
+84 -26
View File
@@ -4,6 +4,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
@@ -58,7 +59,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
final downloadingId = ref.watch( final downloadingId = ref.watch(
storeProvider.select((s) => s.downloadingId), storeProvider.select((s) => s.downloadingId),
); );
final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl)); final hasRegistryUrl = ref.watch(
storeProvider.select((s) => s.hasRegistryUrl),
);
final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl)); final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl));
final filteredExtensions = StoreState( final filteredExtensions = StoreState(
extensions: extensions, extensions: extensions,
@@ -139,7 +142,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
prefixIcon: const Icon(Icons.search), prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty suffixIcon: value.text.isNotEmpty
? IconButton( ? IconButton(
tooltip: 'Clear search', tooltip: 'Clear',
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
@@ -151,23 +154,37 @@ class _StoreTabState extends ConsumerState<StoreTab> {
: null, : null,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
borderSide: BorderSide.none, borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
), ),
filled: true, filled: true,
fillColor: fillColor: colorScheme.surfaceContainerHighest,
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 20,
vertical: 12, vertical: 16,
), ),
), ),
onChanged: (value) { onChanged: (value) {
ref.read(storeProvider.notifier).setSearchQuery(value); ref
.read(storeProvider.notifier)
.setSearchQuery(value);
},
onTapOutside: (_) {
FocusScope.of(context).unfocus();
}, },
); );
}, },
@@ -231,7 +248,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
_CategoryChip( _CategoryChip(
label: context.l10n.storeFilterIntegration, label: context.l10n.storeFilterIntegration,
icon: Icons.link, icon: Icons.link,
isSelected: selectedCategory == StoreCategory.integration, isSelected:
selectedCategory == StoreCategory.integration,
onTap: () => ref onTap: () => ref
.read(storeProvider.notifier) .read(storeProvider.notifier)
.setCategory(StoreCategory.integration), .setCategory(StoreCategory.integration),
@@ -242,8 +260,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
), ),
if (isLoading && extensions.isEmpty) if (isLoading && extensions.isEmpty)
const SliverFillRemaining( const SliverToBoxAdapter(
child: Center(child: CircularProgressIndicator()), child: Padding(
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 6),
),
) )
else if (error != null && extensions.isEmpty) else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme)) SliverFillRemaining(child: _buildErrorState(error, colorScheme))
@@ -309,9 +330,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
context.l10n.storeAddRepoTitle, context.l10n.storeAddRepoTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -322,16 +343,23 @@ class _StoreTabState extends ConsumerState<StoreTab> {
labelText: context.l10n.storeRepoUrlLabel, labelText: context.l10n.storeRepoUrlLabel,
prefixIcon: const Icon(Icons.link), prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outline), borderSide: BorderSide(color: colorScheme.outlineVariant),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.primary, width: 2), borderSide: BorderSide(color: colorScheme.primary, width: 2),
), ),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
autocorrect: false, autocorrect: false,
@@ -347,7 +375,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer), Icon(
Icons.error_outline,
size: 20,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
@@ -416,7 +448,31 @@ class _StoreTabState extends ConsumerState<StoreTab> {
labelText: context.l10n.storeNewRepoUrlLabel, labelText: context.l10n.storeNewRepoUrlLabel,
prefixIcon: const Icon(Icons.link), prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
filled: true,
fillColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
), ),
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
@@ -503,7 +559,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions, hasFilters
? context.l10n.storeEmptyNoResults
: context.l10n.storeEmptyNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
+35 -77
View File
@@ -23,6 +23,7 @@ import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
final _log = AppLogger('TrackMetadata'); final _log = AppLogger('TrackMetadata');
@@ -59,19 +60,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _fileExists = false; bool _fileExists = false;
bool _hasCheckedFile = false; bool _hasCheckedFile = false;
int? _fileSize; int? _fileSize;
String? _lyrics; // Cleaned lyrics for display (no timestamps) String? _lyrics;
String? _rawLyrics; // Raw LRC with timestamps for embedding String? _rawLyrics;
bool _lyricsLoading = false; bool _lyricsLoading = false;
String? _lyricsError; String? _lyricsError;
String? _lyricsSource; String? _lyricsSource;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false; bool _lyricsEmbedded = false;
bool _isEmbedding = false; // Track embed operation in progress bool _isEmbedding = false;
bool _isInstrumental = false; bool _isInstrumental = false;
bool _isConverting = false; // Track convert operation in progress bool _isConverting = false;
bool _hasMetadataChanges = false; bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false; bool _hasLoadedResolvedAudioMetadata = false;
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit Map<String, dynamic>? _editedMetadata;
String? _embeddedCoverPreviewPath; String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern = RegExp( static final RegExp _lrcTimestampPattern = RegExp(
@@ -307,12 +308,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
storedQuality: _quality, storedQuality: _quality,
); );
// Fill in album name from file tags if stored value is empty
final needsAlbum = final needsAlbum =
resolvedAlbum != null && resolvedAlbum != null &&
resolvedAlbum.isNotEmpty && resolvedAlbum.isNotEmpty &&
(albumName.isEmpty); (albumName.isEmpty);
// Fill in duration from file if stored value is missing/zero
final needsDuration = final needsDuration =
resolvedDuration != null && resolvedDuration != null &&
resolvedDuration > 0 && resolvedDuration > 0 &&
@@ -519,6 +518,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _filePath => String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String get _coverHeroTag =>
_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId';
String? get _coverUrl => String? get _coverUrl =>
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl); _isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
String? get _localCoverPath => String? get _localCoverPath =>
@@ -527,7 +528,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _service => _isLocalItem ? 'local' : _downloadItem!.service; String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt { DateTime get _addedAt {
if (_isLocalItem) { if (_isLocalItem) {
// Use file modification time if available, otherwise fall back to scannedAt
final modTime = _localLibraryItem!.fileModTime; final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) { if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime); return DateTime.fromMillisecondsSinceEpoch(modTime);
@@ -577,7 +577,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get cleanFilePath { String get cleanFilePath {
var path = _filePath; var path = _filePath;
if (path.startsWith('EXISTS:')) path = path.substring(7); if (path.startsWith('EXISTS:')) path = path.substring(7);
// Strip CUE virtual path suffix for filesystem operations
if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path); if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path);
return path; return path;
} }
@@ -770,6 +769,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_buildLyricsCard(context, colorScheme), _buildLyricsCard(context, colorScheme),
if (_fileExists) ...[
const SizedBox(height: 16),
AudioAnalysisCard(filePath: _filePath),
],
const SizedBox(height: 24), const SizedBox(height: 24),
_buildActionButtons(context, ref, colorScheme, _fileExists), _buildActionButtons(context, ref, colorScheme, _fileExists),
@@ -790,38 +794,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
double expandedHeight, double expandedHeight,
bool showContent, bool showContent,
) { ) {
return Stack( final coverChild = _hasPath(_embeddedCoverPreviewPath)
fit: StackFit.expand, ? Image.file(
children: [
if (_hasPath(_embeddedCoverPreviewPath))
Image.file(
File(_embeddedCoverPreviewPath!), File(_embeddedCoverPreviewPath!),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface), errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
) )
else if (_coverUrl != null) : _coverUrl != null
CachedNetworkImage( ? CachedNetworkImage(
imageUrl: _coverUrl!, imageUrl: _coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface), placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface),
) )
else if (_localCoverPath != null && _localCoverPath!.isNotEmpty) : _localCoverPath != null && _localCoverPath!.isNotEmpty
Image.file( ? Image.file(
File(_localCoverPath!), File(_localCoverPath!),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface), errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
) )
else : Container(
Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
Icons.music_note, Icons.music_note,
size: 80, size: 80,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), );
return Stack(
fit: StackFit.expand,
children: [
Hero(
tag: _coverHeroTag,
child: Material(color: Colors.transparent, child: coverChild),
),
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@@ -1614,7 +1622,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
), ),
// Show "Embed Lyrics" button if lyrics are from online (not already embedded)
if (!_lyricsEmbedded && _fileExists) ...[ if (!_lyricsEmbedded && _fileExists) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Center( Center(
@@ -1662,7 +1669,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
try { try {
final durationMs = (duration ?? 0) * 1000; final durationMs = (duration ?? 0) * 1000;
// First, check if lyrics are embedded in the file
if (_fileExists) { if (_fileExists) {
final embeddedResult = final embeddedResult =
await PlatformBridge.getLyricsLRCWithSource( await PlatformBridge.getLyricsLRCWithSource(
@@ -1696,12 +1702,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
// No embedded lyrics, fetch from online
final result = await PlatformBridge.getLyricsLRCWithSource( final result = await PlatformBridge.getLyricsLRCWithSource(
_spotifyId ?? '', _spotifyId ?? '',
trackName, trackName,
artistName, artistName,
filePath: null, // Don't check file again filePath: null,
durationMs: durationMs, durationMs: durationMs,
).timeout(const Duration(seconds: 20)); ).timeout(const Duration(seconds: 20));
@@ -1727,9 +1732,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final cleanLyrics = _cleanLrcForDisplay(lrcText); final cleanLyrics = _cleanLrcForDisplay(lrcText);
setState(() { setState(() {
_lyrics = cleanLyrics; _lyrics = cleanLyrics;
_rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding _rawLyrics = lrcText;
_lyricsSource = source.isNotEmpty ? source : null; _lyricsSource = source.isNotEmpty ? source : null;
_lyricsEmbedded = false; // Lyrics from online, not embedded _lyricsEmbedded = false;
_lyricsLoading = false; _lyricsLoading = false;
}); });
} }
@@ -1756,7 +1761,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
setState(() => _isEmbedding = true); setState(() => _isEmbedding = true);
// Capture l10n strings before async gaps to avoid use_build_context_synchronously
final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage; final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage;
final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics; final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics;
final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat; final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat;
@@ -1986,7 +1990,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return; return;
} }
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri; final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? ''; final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) { if (treeUri != null && treeUri.isNotEmpty) {
@@ -2033,7 +2036,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return; return;
} }
// Regular file path
final dir = _getFileDirectory(); final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg'; final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
@@ -2126,7 +2128,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return; return;
} }
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri; final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? ''; final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) { if (treeUri != null && treeUri.isNotEmpty) {
@@ -2182,7 +2183,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return; return;
} }
// Regular file path
final dir = _getFileDirectory(); final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc'; final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
@@ -2257,7 +2257,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final result = await PlatformBridge.reEnrichFile(request); final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?; final method = result['method'] as String?;
// Update local UI state with enriched metadata from online search
final enriched = result['enriched_metadata'] as Map<String, dynamic>?; final enriched = result['enriched_metadata'] as Map<String, dynamic>?;
if (enriched != null && mounted) { if (enriched != null && mounted) {
setState(() { setState(() {
@@ -2344,7 +2343,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
); );
} }
// For SAF files, copy processed temp file back
if (ffmpegResult != null && tempPath != null && safUri != null) { if (ffmpegResult != null && tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) { if (!ok && mounted) {
@@ -2357,7 +2355,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
); );
// Cleanup temp files
if (_hasPath(downloadedCoverPath)) { if (_hasPath(downloadedCoverPath)) {
try { try {
await File(downloadedCoverPath!).delete(); await File(downloadedCoverPath!).delete();
@@ -2375,7 +2372,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
// Cleanup temp files
if (tempPath != null && tempPath.isNotEmpty) { if (tempPath != null && tempPath.isNotEmpty) {
try { try {
await File(tempPath).delete(); await File(tempPath).delete();
@@ -2397,7 +2393,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
); );
} }
// Cleanup temp cover from Go backend
if (_hasPath(downloadedCoverPath)) { if (_hasPath(downloadedCoverPath)) {
try { try {
await File(downloadedCoverPath!).delete(); await File(downloadedCoverPath!).delete();
@@ -2462,7 +2457,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
for (final line in lines) { for (final line in lines) {
var cleaned = line.trim(); var cleaned = line.trim();
// Skip metadata tags
if (_lrcMetadataPattern.hasMatch(cleaned) && if (_lrcMetadataPattern.hasMatch(cleaned) &&
!_lrcBackgroundLinePattern.hasMatch(cleaned)) { !_lrcBackgroundLinePattern.hasMatch(cleaned)) {
continue; continue;
@@ -2474,7 +2468,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
cleaned = bgMatch.group(1)?.trim() ?? ''; cleaned = bgMatch.group(1)?.trim() ?? '';
} }
// Remove line timestamp, inline word-by-word timestamps, and speaker prefix.
cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim(); cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim();
cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, ''); cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, '');
cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, ''); cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, '');
@@ -2685,11 +2678,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
/// Whether the current file is a CUE sheet (or CUE-referenced) /// Whether the current file is a CUE sheet (or CUE-referenced)
bool get _isCueFile { bool get _isCueFile {
// Check if the raw path has a CUE virtual path suffix
if (isCueVirtualPath(rawFilePath)) return true; if (isCueVirtualPath(rawFilePath)) return true;
final lower = cleanFilePath.toLowerCase(); final lower = cleanFilePath.toLowerCase();
if (lower.endsWith('.cue')) return true; if (lower.endsWith('.cue')) return true;
// Check if local library item has cue+ format
if (_isLocalItem && _localLibraryItem != null) { if (_isLocalItem && _localLibraryItem != null) {
final format = _localLibraryItem!.format ?? ''; final format = _localLibraryItem!.format ?? '';
if (format.startsWith('cue+')) return true; if (format.startsWith('cue+')) return true;
@@ -2815,7 +2806,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final currentFormat = _currentFileFormat; final currentFormat = _currentFileFormat;
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
// Build available target formats based on source
final formats = <String>[]; final formats = <String>[];
if (currentFormat == 'FLAC') { if (currentFormat == 'FLAC') {
formats.addAll(['ALAC', 'MP3', 'Opus']); formats.addAll(['ALAC', 'MP3', 'Opus']);
@@ -2906,7 +2896,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}).toList(), }).toList(),
), ),
// Only show bitrate for lossy targets
if (!isLosslessTarget) ...[ if (!isLosslessTarget) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
@@ -2933,7 +2922,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
], ],
// Show lossless indicator
if (isLosslessTarget && isLosslessSource) ...[ if (isLosslessTarget && isLosslessSource) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -2991,14 +2979,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
void _showCueSplitSheet(BuildContext context) async { void _showCueSplitSheet(BuildContext context) async {
// Strip the #trackNN suffix from virtual CUE paths to get the real .cue path
var cuePath = cleanFilePath; var cuePath = cleanFilePath;
final trackSuffix = RegExp(r'#track\d+$'); final trackSuffix = RegExp(r'#track\d+$');
if (trackSuffix.hasMatch(cuePath)) { if (trackSuffix.hasMatch(cuePath)) {
cuePath = cuePath.replaceFirst(trackSuffix, ''); cuePath = cuePath.replaceFirst(trackSuffix, '');
} }
// Show loading indicator
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)), SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)),
); );
@@ -3093,7 +3079,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Track list preview (scrollable, max 200px)
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200), constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder( child: ListView.builder(
@@ -3315,7 +3300,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
workingAudioPath = tempPath; workingAudioPath = tempPath;
} }
// Determine output directory
final String outputDir; final String outputDir;
final treeUri = !_isLocalItem final treeUri = !_isLocalItem
? (_downloadItem?.downloadTreeUri ?? '') ? (_downloadItem?.downloadTreeUri ?? '')
@@ -3342,7 +3326,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!mounted) return; if (!mounted) return;
_showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length));
// Extract cover from audio file for embedding
String? coverPath; String? coverPath;
try { try {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
@@ -3385,11 +3368,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
for (final path in finalOutputPaths) { for (final path in finalOutputPaths) {
if (path.toLowerCase().endsWith('.flac')) { if (path.toLowerCase().endsWith('.flac')) {
try { try {
// Read existing metadata first
final metadata = await PlatformBridge.readFileMetadata(path); final metadata = await PlatformBridge.readFileMetadata(path);
if (metadata['error'] == null) { if (metadata['error'] == null) {
final fields = <String, String>{'cover_path': coverPath}; final fields = <String, String>{'cover_path': coverPath};
// Preserve existing fields
for (final entry in metadata.entries) { for (final entry in metadata.entries) {
if (entry.key == 'error' || entry.value == null) continue; if (entry.key == 'error' || entry.value == null) continue;
final v = entry.value.toString().trim(); final v = entry.value.toString().trim();
@@ -3415,7 +3396,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
finalOutputPaths = exportedUris; finalOutputPaths = exportedUris;
} }
// Cleanup cover temp
if (coverPath != null) { if (coverPath != null) {
try { try {
await File(coverPath).delete(); await File(coverPath).delete();
@@ -3437,7 +3417,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_showSnackBarMessage(_l10nCueSplitFailed); _showSnackBarMessage(_l10nCueSplitFailed);
} }
} finally { } finally {
// Cleanup SAF temp audio copy
if (safTempAudioPath != null) { if (safTempAudioPath != null) {
try { try {
await File(safTempAudioPath).delete(); await File(safTempAudioPath).delete();
@@ -3556,7 +3535,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? safTempPath; String? safTempPath;
if (isSaf) { if (isSaf) {
// Copy SAF file to temp for processing
safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath);
if (safTempPath == null) { if (safTempPath == null) {
if (mounted) { if (mounted) {
@@ -3576,10 +3554,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bitrate: bitrate, bitrate: bitrate,
metadata: metadata, metadata: metadata,
coverPath: coverPath, coverPath: coverPath,
deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it deleteOriginal: !isSaf,
); );
// Cleanup cover temp
if (coverPath != null) { if (coverPath != null) {
try { try {
await File(coverPath).delete(); await File(coverPath).delete();
@@ -3587,7 +3564,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
if (newPath == null) { if (newPath == null) {
// Cleanup SAF temp if needed
if (safTempPath != null) { if (safTempPath != null) {
try { try {
await File(safTempPath).delete(); await File(safTempPath).delete();
@@ -3649,7 +3625,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
newExt = '.flac'; newExt = '.flac';
mimeType = 'audio/flac'; mimeType = 'audio/flac';
break; break;
default: // mp3 default:
newExt = '.mp3'; newExt = '.mp3';
mimeType = 'audio/mpeg'; mimeType = 'audio/mpeg';
break; break;
@@ -3689,7 +3665,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_log.w('Converted SAF file created but failed deleting original URI'); _log.w('Converted SAF file created but failed deleting original URI');
} }
// Update history with new SAF info
if (!_isLocalItem) { if (!_isLocalItem) {
await HistoryDatabase.instance.updateFilePath( await HistoryDatabase.instance.updateFilePath(
_downloadItem!.id, _downloadItem!.id,
@@ -3701,7 +3676,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
} }
// Cleanup temp files
try { try {
await File(newPath).delete(); await File(newPath).delete();
} catch (_) {} } catch (_) {}
@@ -3711,7 +3685,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (_) {} } catch (_) {}
} }
} else { } else {
// Regular file: update history with new path
if (!_isLocalItem) { if (!_isLocalItem) {
await HistoryDatabase.instance.updateFilePath( await HistoryDatabase.instance.updateFilePath(
_downloadItem!.id, _downloadItem!.id,
@@ -3730,7 +3703,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
content: Text(context.l10n.trackConvertSuccess(targetFormat)), content: Text(context.l10n.trackConvertSuccess(targetFormat)),
), ),
); );
// Pop and let the caller refresh
Navigator.pop(context, true); Navigator.pop(context, true);
} }
} catch (e) { } catch (e) {
@@ -3748,7 +3720,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref, WidgetRef ref,
ColorScheme colorScheme, ColorScheme colorScheme,
) async { ) async {
// Read current metadata from file, fall back to item data on failure
Map<String, dynamic>? fileMetadata; Map<String, dynamic>? fileMetadata;
try { try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath); final result = await PlatformBridge.readFileMetadata(cleanFilePath);
@@ -3759,7 +3730,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
debugPrint('readFileMetadata failed, using item data: $e'); debugPrint('readFileMetadata failed, using item data: $e');
} }
// Build initial values map prefer file metadata, fall back to item data
String val(String key, String? fallback) { String val(String key, String? fallback) {
final v = fileMetadata?[key]?.toString(); final v = fileMetadata?[key]?.toString();
return (v != null && v.isNotEmpty) ? v : (fallback ?? ''); return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
@@ -3805,7 +3775,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
ScaffoldMessenger.of(this.context).showSnackBar( ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)), SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)),
); );
// Re-read metadata from file to refresh the display
try { try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath); final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
setState(() => _editedMetadata = refreshed); setState(() => _editedMetadata = refreshed);
@@ -4050,10 +4019,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
String? _currentCoverTempDir; String? _currentCoverTempDir;
bool _loadingCurrentCover = false; bool _loadingCurrentCover = false;
// Auto-fill field selection which fields the user wants to fetch
final Set<String> _autoFillFields = {}; final Set<String> _autoFillFields = {};
// All auto-fillable fields and their mapping
static const _fieldDefs = <String, String>{ static const _fieldDefs = <String, String>{
'title': 'title', 'title': 'title',
'artist': 'artist', 'artist': 'artist',
@@ -4679,7 +4646,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
throw StateError('No metadata match resolved for auto-fill'); throw StateError('No metadata match resolved for auto-fill');
} }
// Extract basic metadata from search result
final enriched = <String, String>{ final enriched = <String, String>{
'title': (selectedBest['name'] ?? '').toString(), 'title': (selectedBest['name'] ?? '').toString(),
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
@@ -4757,7 +4723,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return; if (!mounted) return;
// Fetch genre/label/copyright from Deezer extended metadata
if (needsExtended && deezerId != null) { if (needsExtended && deezerId != null) {
try { try {
final extended = await PlatformBridge.getDeezerExtendedMetadata( final extended = await PlatformBridge.getDeezerExtendedMetadata(
@@ -4775,10 +4740,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return; if (!mounted) return;
// Apply selected fields to controllers
var filledCount = 0; var filledCount = 0;
for (final key in _autoFillFields) { for (final key in _autoFillFields) {
if (key == 'cover') continue; // Handle cover separately below if (key == 'cover') continue;
final value = enriched[key]; final value = enriched[key];
if (value != null && if (value != null &&
value.isNotEmpty && value.isNotEmpty &&
@@ -4792,7 +4756,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
} }
} }
// Handle cover art download
if (_autoFillFields.contains('cover')) { if (_autoFillFields.contains('cover')) {
final coverUrl = final coverUrl =
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '') (selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
@@ -5071,7 +5034,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return; return;
} }
// For SAF files, copy the processed temp file back
if (tempPath != null && safUri != null) { if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) { if (!ok && mounted) {
@@ -5184,7 +5146,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
), ),
_field('Genre', _genreCtrl), _field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl), _field('ISRC', _isrcCtrl),
// Advanced fields toggle
Padding( Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4), padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell( child: InkWell(
@@ -5282,7 +5243,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Quick select buttons
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row( child: Row(
@@ -5302,7 +5262,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Field chips
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap( child: Wrap(
@@ -5339,7 +5298,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// Fetch button
Padding( Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
child: SizedBox( child: SizedBox(
+2 -2
View File
@@ -198,8 +198,8 @@ class CsvImportService {
artistName: artistName ?? 'Unknown Artist', artistName: artistName ?? 'Unknown Artist',
albumName: albumName ?? 'Unknown Album', albumName: albumName ?? 'Unknown Album',
isrc: isrc, isrc: isrc,
duration: 0, // Will be updated by enrichment later duration: 0,
coverUrl: null, // Will be fetched by enrichment coverUrl: null,
), ),
); );
} }
-7
View File
@@ -1437,7 +1437,6 @@ class FFmpegService {
final cmdBuffer = StringBuffer(); final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" '); cmdBuffer.write('-i "$inputPath" ');
// Cover art as second input for M4A attached picture
final hasCover = final hasCover =
coverPath != null && coverPath != null &&
coverPath.trim().isNotEmpty && coverPath.trim().isNotEmpty &&
@@ -1455,7 +1454,6 @@ class FFmpegService {
cmdBuffer.write('-c:a alac '); cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 '); cmdBuffer.write('-map_metadata -1 ');
// Embed M4A metadata tags
final m4aTags = _convertToM4aTags(metadata); final m4aTags = _convertToM4aTags(metadata);
for (final entry in m4aTags.entries) { for (final entry in m4aTags.entries) {
final sanitized = entry.value.replaceAll('"', '\\"'); final sanitized = entry.value.replaceAll('"', '\\"');
@@ -1764,7 +1762,6 @@ class FFmpegService {
final outputPaths = <String>[]; final outputPaths = <String>[];
final inputExt = audioPath.toLowerCase().split('.').last; final inputExt = audioPath.toLowerCase().split('.').last;
// For lossless formats, keep as FLAC; for others, keep original format
final outputExt = final outputExt =
(inputExt == 'flac' || (inputExt == 'flac' ||
inputExt == 'wav' || inputExt == 'wav' ||
@@ -1836,14 +1833,10 @@ class FFmpegService {
final result = await _execute(command); final result = await _execute(command);
if (!result.success) { if (!result.success) {
_log.e('CUE split failed for track ${track.number}: ${result.output}'); _log.e('CUE split failed for track ${track.number}: ${result.output}');
// Continue with remaining tracks instead of failing completely
continue; continue;
} }
// Embed cover art if available (for FLAC output)
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') { if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
// Use the Go backend for FLAC cover embedding via PlatformBridge
// (handled by the caller)
} }
outputPaths.add(outputPath); outputPaths.add(outputPath);
+37
View File
@@ -328,6 +328,20 @@ class HistoryDatabase {
); );
} }
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
if (items.isEmpty) return;
final db = await database;
final batch = db.batch();
for (final json in items) {
batch.insert(
'history',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
}
/// Get all history items ordered by download date (newest first) /// Get all history items ordered by download date (newest first)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async { Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database; final db = await database;
@@ -532,6 +546,29 @@ class HistoryDatabase {
return rows.map((r) => Map<String, dynamic>.from(r)).toList(); return rows.map((r) => Map<String, dynamic>.from(r)).toList();
} }
Future<List<Map<String, dynamic>>> getEntriesWithPathsPage({
required int limit,
int offset = 0,
}) async {
final db = await database;
final rows = await db.query(
'history',
columns: [
'id',
'file_path',
'storage_mode',
'download_tree_uri',
'saf_relative_dir',
'saf_file_name',
],
where: 'file_path IS NOT NULL AND file_path != ""',
orderBy: 'downloaded_at DESC, id DESC',
limit: limit,
offset: offset,
);
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Delete multiple entries by IDs /// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> ids) async { Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0; if (ids.isEmpty) return 0;
+32 -11
View File
@@ -255,20 +255,41 @@ class LibraryDatabase {
Future<void> upsertBatch(List<Map<String, dynamic>> items) async { Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
if (items.isEmpty) return; if (items.isEmpty) return;
final db = await database; final db = await database;
final batch = db.batch(); await db.transaction((txn) async {
final batch = txn.batch();
for (final json in items) { for (final json in items) {
batch.insert( batch.insert(
'library', 'library',
_jsonToDbRow(json), _jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
} }
await batch.commit(noResult: true);
await batch.commit(noResult: true); });
_log.i('Batch inserted ${items.length} items'); _log.i('Batch inserted ${items.length} items');
} }
Future<void> replaceAll(List<Map<String, dynamic>> items) async {
final db = await database;
await db.transaction((txn) async {
await txn.delete('library');
if (items.isEmpty) {
return;
}
final batch = txn.batch();
for (final json in items) {
batch.insert(
'library',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
});
_log.i('Replaced library with ${items.length} items');
}
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async { Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database; final db = await database;
final rows = await db.query( final rows = await db.query(
+23 -39
View File
@@ -83,24 +83,18 @@ class PlatformBridge {
static Future<Map<String, dynamic>> getDownloadProgress() async { static Future<Map<String, dynamic>> getDownloadProgress() async {
final result = await _channel.invokeMethod('getDownloadProgress'); final result = await _channel.invokeMethod('getDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return _decodeMapResult(result);
} }
static Future<Map<String, dynamic>> getAllDownloadProgress() async { static Future<Map<String, dynamic>> getAllDownloadProgress() async {
final result = await _channel.invokeMethod('getAllDownloadProgress'); final result = await _channel.invokeMethod('getAllDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return _decodeMapResult(result);
} }
static Stream<Map<String, dynamic>> downloadProgressStream() { static Stream<Map<String, dynamic>> downloadProgressStream() {
return _downloadProgressEvents.receiveBroadcastStream().map((event) { return _downloadProgressEvents.receiveBroadcastStream().map(
if (event is String) { _decodeMapResult,
return jsonDecode(event) as Map<String, dynamic>; );
}
if (event is Map) {
return Map<String, dynamic>.from(event);
}
return const <String, dynamic>{};
});
} }
static Future<void> exitApp() async { static Future<void> exitApp() async {
@@ -1087,7 +1081,6 @@ class PlatformBridge {
} }
} }
/// Set the directory for caching extracted cover art
static Future<void> setLibraryCoverCacheDir(String cacheDir) async { static Future<void> setLibraryCoverCacheDir(String cacheDir) async {
_log.i('setLibraryCoverCacheDir: $cacheDir'); _log.i('setLibraryCoverCacheDir: $cacheDir');
await _channel.invokeMethod('setLibraryCoverCacheDir', { await _channel.invokeMethod('setLibraryCoverCacheDir', {
@@ -1095,8 +1088,6 @@ class PlatformBridge {
}); });
} }
/// Scan a folder for audio files and read their metadata
/// Returns a list of track metadata
static Future<List<Map<String, dynamic>>> scanLibraryFolder( static Future<List<Map<String, dynamic>>> scanLibraryFolder(
String folderPath, String folderPath,
) async { ) async {
@@ -1108,10 +1099,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Perform an incremental scan of the library folder
/// Only scans files that are new or have changed since last scan
/// [existingFiles] is a map of filePath -> modTime (unix millis)
/// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
static Future<Map<String, dynamic>> scanLibraryFolderIncremental( static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
String folderPath, String folderPath,
Map<String, int> existingFiles, Map<String, int> existingFiles,
@@ -1146,8 +1133,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Incremental SAF tree scan - only scans new or modified files
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
static Future<Map<String, dynamic>> scanSafTreeIncremental( static Future<Map<String, dynamic>> scanSafTreeIncremental(
String treeUri, String treeUri,
Map<String, int> existingFiles, Map<String, int> existingFiles,
@@ -1173,8 +1158,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
/// Get last-modified timestamps for a list of SAF file URIs.
/// Returns map uri -> modTime (unix millis), only for files that still exist.
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async { static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
final result = await _channel.invokeMethod('getSafFileModTimes', { final result = await _channel.invokeMethod('getSafFileModTimes', {
'uris': jsonEncode(uris), 'uris': jsonEncode(uris),
@@ -1183,29 +1166,35 @@ class PlatformBridge {
return map.map((key, value) => MapEntry(key, (value as num).toInt())); return map.map((key, value) => MapEntry(key, (value as num).toInt()));
} }
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async { static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress'); final result = await _channel.invokeMethod('getLibraryScanProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return _decodeMapResult(result);
} }
static Stream<Map<String, dynamic>> libraryScanProgressStream() { static Stream<Map<String, dynamic>> libraryScanProgressStream() {
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) { return _libraryScanProgressEvents.receiveBroadcastStream().map(
if (event is String) { _decodeMapResult,
return jsonDecode(event) as Map<String, dynamic>; );
}
if (event is Map) {
return Map<String, dynamic>.from(event);
}
return const <String, dynamic>{};
});
} }
/// Cancel ongoing library scan
static Future<void> cancelLibraryScan() async { static Future<void> cancelLibraryScan() async {
await _channel.invokeMethod('cancelLibraryScan'); await _channel.invokeMethod('cancelLibraryScan');
} }
static Map<String, dynamic> _decodeMapResult(dynamic result) {
if (result is Map) {
return Map<String, dynamic>.from(result);
}
if (result is String) {
if (result.isEmpty) return const <String, dynamic>{};
final decoded = jsonDecode(result);
if (decoded is Map) {
return Map<String, dynamic>.from(decoded);
}
}
return const <String, dynamic>{};
}
// MARK: - iOS Security-Scoped Bookmark // MARK: - iOS Security-Scoped Bookmark
/// Create a security-scoped bookmark from a filesystem path picked by /// Create a security-scoped bookmark from a filesystem path picked by
@@ -1247,7 +1236,6 @@ class PlatformBridge {
} }
} }
/// Read metadata from a single audio file
static Future<Map<String, dynamic>?> readAudioMetadata( static Future<Map<String, dynamic>?> readAudioMetadata(
String filePath, String filePath,
) async { ) async {
@@ -1367,10 +1355,6 @@ class PlatformBridge {
await _channel.invokeMethod('clearStoreCache'); await _channel.invokeMethod('clearStoreCache');
} }
/// Parse a .cue file and return split information (track listing, timing, metadata).
/// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[]
/// Each track has: number, title, artist, isrc, composer, start_sec, end_sec
/// [audioDir] optionally overrides the directory for audio file resolution (used for SAF).
static Future<Map<String, dynamic>> parseCueSheet( static Future<Map<String, dynamic>> parseCueSheet(
String cuePath, { String cuePath, {
String audioDir = '', String audioDir = '',
-3
View File
@@ -80,7 +80,6 @@ class ShareIntentService {
bool isInitial = false, bool isInitial = false,
}) { }) {
for (final file in files) { for (final file in files) {
// Check both path and message - apps may share URL in either field
final textsToCheck = [file.path, if (file.message != null) file.message!]; final textsToCheck = [file.path, if (file.message != null) file.message!];
for (final textToCheck in textsToCheck) { for (final textToCheck in textsToCheck) {
@@ -100,13 +99,11 @@ class ShareIntentService {
String? _extractMusicUrl(String text) { String? _extractMusicUrl(String text) {
if (text.isEmpty) return null; if (text.isEmpty) return null;
// Try Spotify URI first
final uriMatch = _spotifyUriPattern.firstMatch(text); final uriMatch = _spotifyUriPattern.firstMatch(text);
if (uriMatch != null) { if (uriMatch != null) {
return uriMatch.group(0); return uriMatch.group(0);
} }
// Try all URL patterns
final patterns = [ final patterns = [
_spotifyUrlPattern, _spotifyUrlPattern,
_deezerUrlPattern, _deezerUrlPattern,
+4 -10
View File
@@ -89,9 +89,7 @@ class AppTheme {
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData( static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
borderRadius: BorderRadius.circular(16),
),
color: scheme.surfaceContainerLow, color: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint, surfaceTintColor: scheme.surfaceTint,
); );
@@ -148,9 +146,7 @@ class AppTheme {
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) => static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
InputDecorationTheme( InputDecorationTheme(
filled: true, filled: true,
fillColor: scheme.surfaceContainerHighest.withValues( fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.3),
alpha: 0.3,
),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none, borderSide: BorderSide.none,
@@ -175,9 +171,7 @@ class AppTheme {
static ListTileThemeData _listTileTheme(ColorScheme scheme) => static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
ListTileThemeData( ListTileThemeData(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
borderRadius: BorderRadius.circular(16),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
); );
@@ -237,7 +231,7 @@ class AppTheme {
); );
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData( static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: scheme.surfaceContainerLow, backgroundColor: scheme.surfaceContainerLow,
selectedColor: scheme.secondaryContainer, selectedColor: scheme.secondaryContainer,
); );
+10 -10
View File
@@ -27,7 +27,6 @@ Future<void> navigateToArtist(
final normalizedArtistId = _normalizeArtistId(artistId); final normalizedArtistId = _normalizeArtistId(artistId);
// If we have a valid artist ID already, navigate directly
if (normalizedArtistId != null && if (normalizedArtistId != null &&
_canNavigateArtistDirectly( _canNavigateArtistDirectly(
artistId: normalizedArtistId, artistId: normalizedArtistId,
@@ -43,7 +42,6 @@ Future<void> navigateToArtist(
return; return;
} }
// Search Deezer to resolve the artist ID
_showLoadingSnackBar(context, 'Looking up artist...'); _showLoadingSnackBar(context, 'Looking up artist...');
try { try {
final results = await PlatformBridge.searchDeezerAll( final results = await PlatformBridge.searchDeezerAll(
@@ -60,7 +58,6 @@ Future<void> navigateToArtist(
return; return;
} }
// Find best match - prefer exact name match (case-insensitive)
Map<String, dynamic>? bestMatch; Map<String, dynamic>? bestMatch;
final lowerName = artistName.toLowerCase().trim(); final lowerName = artistName.toLowerCase().trim();
for (final a in artistList) { for (final a in artistList) {
@@ -113,7 +110,6 @@ Future<void> navigateToAlbum(
}) async { }) async {
if (albumName.isEmpty) return; if (albumName.isEmpty) return;
// If we have a valid album ID already, navigate directly
if (albumId != null && if (albumId != null &&
albumId.isNotEmpty && albumId.isNotEmpty &&
albumId != 'unknown' && albumId != 'unknown' &&
@@ -128,16 +124,13 @@ Future<void> navigateToAlbum(
return; return;
} }
// If it's extension-based content without an ID, can't search Deezer for it
if (extensionId != null) { if (extensionId != null) {
_showUnavailable(context, 'Album'); _showUnavailable(context, 'Album');
return; return;
} }
// Search Deezer to resolve the album ID
_showLoadingSnackBar(context, 'Looking up album...'); _showLoadingSnackBar(context, 'Looking up album...');
try { try {
// Build search query: "albumName artistName" for better accuracy
final query = artistName != null && artistName.isNotEmpty final query = artistName != null && artistName.isNotEmpty
? '$albumName $artistName' ? '$albumName $artistName'
: albumName; : albumName;
@@ -156,7 +149,6 @@ Future<void> navigateToAlbum(
return; return;
} }
// Find best match - prefer exact name match (case-insensitive)
Map<String, dynamic>? bestMatch; Map<String, dynamic>? bestMatch;
final lowerName = albumName.toLowerCase().trim(); final lowerName = albumName.toLowerCase().trim();
for (final a in albumList) { for (final a in albumList) {
@@ -225,11 +217,19 @@ void _pushAlbumScreen(
String? coverUrl, String? coverUrl,
String? extensionId, String? extensionId,
}) { }) {
// Built-in providers (tidal, qobuz, deezer) use AlbumScreen which
// detects the provider from the album ID prefix. Only true JS extensions
// should use ExtensionAlbumScreen.
const builtInProviders = {'tidal', 'qobuz', 'deezer'};
final isExtension =
extensionId != null && !builtInProviders.contains(extensionId);
final resolvedExtensionId = extensionId;
_pushViaPreferredNavigator( _pushViaPreferredNavigator(
context, context,
(context) => extensionId != null (context) => isExtension && resolvedExtensionId != null
? ExtensionAlbumScreen( ? ExtensionAlbumScreen(
extensionId: extensionId, extensionId: resolvedExtensionId,
albumId: albumId, albumId: albumId,
albumName: albumName, albumName: albumName,
coverUrl: coverUrl, coverUrl: coverUrl,
+14 -1
View File
@@ -22,6 +22,9 @@ const _audioExtensions = <String>[
'.aac', '.aac',
]; ];
const _maxPathMatchKeyCacheSize = 6000;
final Map<String, Set<String>> _pathMatchKeyCache = <String, Set<String>>{};
/// Strips a trailing audio extension from [path] if present. /// Strips a trailing audio extension from [path] if present.
/// Returns the path without extension, or `null` if no known audio extension /// Returns the path without extension, or `null` if no known audio extension
/// was found. /// was found.
@@ -41,6 +44,11 @@ Set<String> buildPathMatchKeys(String? filePath) {
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw; final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
if (cleaned.isEmpty) return const {}; if (cleaned.isEmpty) return const {};
final cached = _pathMatchKeyCache.remove(cleaned);
if (cached != null) {
_pathMatchKeyCache[cleaned] = cached;
return cached;
}
final keys = <String>{}; final keys = <String>{};
final visited = <String>{}; final visited = <String>{};
@@ -118,7 +126,12 @@ Set<String> buildPathMatchKeys(String? filePath) {
} }
keys.addAll(extensionStrippedKeys); keys.addAll(extensionStrippedKeys);
return keys; final result = Set<String>.unmodifiable(keys);
_pathMatchKeyCache[cleaned] = result;
while (_pathMatchKeyCache.length > _maxPathMatchKeyCacheSize) {
_pathMatchKeyCache.remove(_pathMatchKeyCache.keys.first);
}
return result;
} }
Iterable<String> _androidEquivalentPaths(String path) { Iterable<String> _androidEquivalentPaths(String path) {
+879
View File
@@ -0,0 +1,879 @@
import 'package:flutter/material.dart';
//
// 1. Staggered List Item fade + slide-up entrance with index-based delay
//
/// Wraps a child in a staggered fade-in + slide-up animation.
///
/// [index] controls the stagger delay (each item delayed by [staggerDelay]).
/// Set [animate] to false to skip the animation (e.g. when scrolling back).
class StaggeredListItem extends StatelessWidget {
static const int _defaultMaxAnimatedItems = 10;
final int index;
final Widget child;
final Duration duration;
final Duration staggerDelay;
final bool animate;
final int maxAnimatedItems;
const StaggeredListItem({
super.key,
required this.index,
required this.child,
this.duration = const Duration(milliseconds: 250),
this.staggerDelay = const Duration(milliseconds: 40),
this.animate = true,
this.maxAnimatedItems = _defaultMaxAnimatedItems,
});
@override
Widget build(BuildContext context) {
if (!animate || index >= maxAnimatedItems) return child;
// Cap the delay so very long lists don't have absurd wait times.
final cappedIndex = index.clamp(0, maxAnimatedItems - 1);
final delay = staggerDelay * cappedIndex;
final totalDuration = duration + delay;
return TweenAnimationBuilder<double>(
key: ValueKey('stagger_$index'),
tween: Tween(begin: 0.0, end: 1.0),
duration: totalDuration,
curve: Curves.easeOutCubic,
builder: (context, value, child) {
// Compute the effective progress after the stagger delay.
final delayFraction = totalDuration.inMilliseconds > 0
? delay.inMilliseconds / totalDuration.inMilliseconds
: 0.0;
final progress = value <= delayFraction
? 0.0
: ((value - delayFraction) / (1.0 - delayFraction)).clamp(0.0, 1.0);
return Opacity(
opacity: progress,
child: Transform.translate(
offset: Offset(0, 12 * (1 - progress)),
child: child,
),
);
},
child: child,
);
}
}
//
// 2. Animated State Switcher crossfade between loading / content / empty / error
//
/// A convenience wrapper around [AnimatedSwitcher] that crossfades between
/// different widget states (loading, content, empty, error).
///
/// Assign a unique [ValueKey] to each child so the switcher detects changes.
class AnimatedStateSwitcher extends StatelessWidget {
final Widget child;
final Duration duration;
const AnimatedStateSwitcher({
super.key,
required this.child,
this.duration = const Duration(milliseconds: 250),
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: child,
);
}
}
//
// 3. Shared Page Route consistent slide-from-right transition
//
/// Creates a platform-aware material route.
///
/// This intentionally defers route transitions to Flutter's material route and
/// theme so Android predictive back and platform-default animations remain
/// intact.
Route<T> slidePageRoute<T>({required Widget page}) {
return MaterialPageRoute<T>(builder: (context) => page);
}
//
// 4. Shimmer / Skeleton Loading Widget
//
/// A shimmer effect widget that can wrap skeleton placeholders.
class ShimmerLoading extends StatefulWidget {
final Widget child;
const ShimmerLoading({super.key, required this.child});
@override
State<ShimmerLoading> createState() => _ShimmerLoadingState();
}
class _ShimmerLoadingState extends State<ShimmerLoading>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final baseColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.10),
colorScheme.surface,
);
final highlightColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.14),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.01),
colorScheme.surface,
);
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return ShaderMask(
shaderCallback: (bounds) {
return LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [baseColor, highlightColor, baseColor],
stops: [
(_controller.value - 0.3).clamp(0.0, 1.0),
_controller.value,
(_controller.value + 0.3).clamp(0.0, 1.0),
],
tileMode: TileMode.clamp,
).createShader(bounds);
},
blendMode: BlendMode.srcATop,
child: child,
);
},
child: widget.child,
);
}
}
/// A skeleton placeholder box used inside [ShimmerLoading].
class SkeletonBox extends StatelessWidget {
final double width;
final double height;
final double borderRadius;
const SkeletonBox({
super.key,
required this.width,
required this.height,
this.borderRadius = 8,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final color = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.06),
colorScheme.surface,
);
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(borderRadius),
),
);
}
}
/// Track list skeleton mimics a list of track items while loading.
class TrackListSkeleton extends StatelessWidget {
final int itemCount;
final bool showCoverHeader;
const TrackListSkeleton({
super.key,
this.itemCount = 8,
this.showCoverHeader = false,
});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return ShimmerLoading(
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
children: [
if (showCoverHeader) ...[
SkeletonBox(
width: screenWidth,
height: screenWidth * 0.75,
borderRadius: 0,
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: SkeletonBox(width: 180, height: 20, borderRadius: 4),
),
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 20),
child: SkeletonBox(width: 110, height: 14, borderRadius: 4),
),
],
...List.generate(itemCount, (index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
const SkeletonBox(width: 48, height: 48),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(
width: 140 + (index % 3) * 30,
height: 14,
borderRadius: 4,
),
const SizedBox(height: 6),
SkeletonBox(
width: 90 + (index % 2) * 20,
height: 12,
borderRadius: 4,
),
],
),
),
const SkeletonBox(width: 24, height: 24, borderRadius: 12),
],
),
);
}),
],
),
),
);
}
}
/// Grid skeleton mimics a grid of album/playlist cards while loading.
/// Album track list skeleton mimics the album screen track list layout
/// (track number + title + artist + trailing icon, no cover art thumbnail).
class AlbumTrackListSkeleton extends StatelessWidget {
final int itemCount;
final bool showCoverHeader;
const AlbumTrackListSkeleton({
super.key,
this.itemCount = 10,
this.showCoverHeader = false,
});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return ShimmerLoading(
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
children: [
if (showCoverHeader) ...[
SkeletonBox(
width: screenWidth,
height: screenWidth * 0.75,
borderRadius: 0,
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: SkeletonBox(width: 180, height: 20, borderRadius: 4),
),
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 20),
child: SkeletonBox(width: 110, height: 14, borderRadius: 4),
),
],
...List.generate(itemCount, (index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
child: Row(
children: [
SizedBox(
width: 32,
child: Center(
child: SkeletonBox(
width: 14,
height: 14,
borderRadius: 4,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(
width: 120 + (index % 4) * 35,
height: 14,
borderRadius: 4,
),
const SizedBox(height: 6),
SkeletonBox(
width: 70 + (index % 3) * 20,
height: 12,
borderRadius: 4,
),
],
),
),
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
],
),
);
}),
],
),
),
);
}
}
class GridSkeleton extends StatelessWidget {
final int itemCount;
final int crossAxisCount;
const GridSkeleton({super.key, this.itemCount = 6, this.crossAxisCount = 2});
@override
Widget build(BuildContext context) {
return ShimmerLoading(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.78,
),
itemCount: itemCount,
itemBuilder: (context, index) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const AspectRatio(
aspectRatio: 1,
child: SkeletonBox(width: double.infinity, height: 0),
),
const SizedBox(height: 8),
SkeletonBox(
width: 80 + (index % 3) * 20,
height: 12,
borderRadius: 4,
),
const SizedBox(height: 4),
SkeletonBox(
width: 50 + (index % 2) * 15,
height: 10,
borderRadius: 4,
),
],
);
},
),
),
);
}
}
/// Artist screen skeleton mimics the artist page content below the header:
/// an optional "Popular" section (rank + cover 48x48 + title + trailing) then
/// a horizontal-scroll album section.
class ArtistScreenSkeleton extends StatelessWidget {
final int popularCount;
final int albumCount;
const ArtistScreenSkeleton({
super.key,
this.popularCount = 5,
this.albumCount = 5,
});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return ShimmerLoading(
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(
width: screenWidth,
height: screenWidth * 0.75,
borderRadius: 0,
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: SkeletonBox(width: 180, height: 24, borderRadius: 4),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: SkeletonBox(width: 120, height: 14, borderRadius: 4),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SkeletonBox(width: 90, height: 20, borderRadius: 4),
),
...List.generate(popularCount, (index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
SizedBox(
width: 24,
child: Center(
child: SkeletonBox(
width: 12,
height: 14,
borderRadius: 4,
),
),
),
const SizedBox(width: 12),
const SkeletonBox(width: 48, height: 48, borderRadius: 4),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(
width: 110 + (index % 4) * 30,
height: 14,
borderRadius: 4,
),
const SizedBox(height: 6),
SkeletonBox(
width: 70 + (index % 3) * 15,
height: 11,
borderRadius: 4,
),
],
),
),
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
],
),
);
}),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: SkeletonBox(width: 80, height: 20, borderRadius: 4),
),
SizedBox(
height: 190,
child: ListView.builder(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: albumCount,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SkeletonBox(width: 140, height: 140),
const SizedBox(height: 8),
SkeletonBox(
width: 80 + (index % 3) * 20,
height: 12,
borderRadius: 4,
),
const SizedBox(height: 4),
SkeletonBox(
width: 50 + (index % 2) * 15,
height: 10,
borderRadius: 4,
),
],
),
);
},
),
),
],
),
),
);
}
}
/// Home search skeleton mimics filter chips + sectioned results
/// (Artists section with rounded card items, Albums section, etc.)
class HomeSearchSkeleton extends StatelessWidget {
const HomeSearchSkeleton({super.key});
@override
Widget build(BuildContext context) {
return ShimmerLoading(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
SkeletonBox(width: 48, height: 32, borderRadius: 16),
const SizedBox(width: 8),
SkeletonBox(width: 64, height: 32, borderRadius: 16),
const SizedBox(width: 8),
SkeletonBox(width: 72, height: 32, borderRadius: 16),
const SizedBox(width: 8),
SkeletonBox(width: 60, height: 32, borderRadius: 16),
const SizedBox(width: 8),
SkeletonBox(width: 70, height: 32, borderRadius: 16),
],
),
),
const SizedBox(height: 8),
_sectionSkeleton(context, 70, 2),
const SizedBox(height: 16),
_sectionSkeleton(context, 65, 4),
],
),
);
}
static Widget _sectionSkeleton(
BuildContext context,
double headerWidth,
int itemCount,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
SkeletonBox(width: headerWidth, height: 18, borderRadius: 4),
const Spacer(),
const SkeletonBox(width: 50, height: 16, borderRadius: 4),
],
),
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: List.generate(itemCount, (index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
child: Row(
children: [
const SkeletonBox(width: 48, height: 48, borderRadius: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SkeletonBox(
width: 100 + (index % 3) * 40,
height: 14,
borderRadius: 4,
),
const SizedBox(height: 6),
SkeletonBox(
width: 60 + (index % 2) * 25,
height: 12,
borderRadius: 4,
),
],
),
),
const SkeletonBox(width: 20, height: 20, borderRadius: 10),
],
),
);
}),
),
),
],
);
}
}
//
// 5. Animated Selection Checkbox scales in when entering selection mode
//
/// An animated selection indicator that scales in/out and crossfades the
/// checked/unchecked state.
class AnimatedSelectionCheckbox extends StatelessWidget {
final bool visible;
final bool selected;
final ColorScheme colorScheme;
final double size;
/// Background color when not selected. Defaults to `Colors.transparent`.
final Color? unselectedColor;
const AnimatedSelectionCheckbox({
super.key,
required this.visible,
required this.selected,
required this.colorScheme,
this.size = 20,
this.unselectedColor,
});
@override
Widget build(BuildContext context) {
return AnimatedScale(
scale: visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutBack,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: size,
height: size,
decoration: BoxDecoration(
color: selected
? colorScheme.primary
: unselectedColor ?? Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: selected ? colorScheme.primary : colorScheme.outline,
width: 2,
),
),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: selected
? Icon(
Icons.check,
key: const ValueKey('checked'),
size: size - 6,
color: colorScheme.onPrimary,
)
: SizedBox(
key: const ValueKey('unchecked'),
width: size - 6,
height: size - 6,
),
),
),
);
}
}
//
// 6. Download Success Animation green flash + checkmark
//
/// A widget that briefly flashes a success color behind its child and shows
/// an animated checkmark when [showSuccess] transitions to true.
class DownloadSuccessOverlay extends StatefulWidget {
final bool showSuccess;
final Widget child;
const DownloadSuccessOverlay({
super.key,
required this.showSuccess,
required this.child,
});
@override
State<DownloadSuccessOverlay> createState() => _DownloadSuccessOverlayState();
}
class _DownloadSuccessOverlayState extends State<DownloadSuccessOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _flashAnimation;
late bool _wasSuccess;
@override
void initState() {
super.initState();
// Initialise from the current widget value so items that are already
// completed when first built do not play the flash animation.
_wasSuccess = widget.showSuccess;
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_flashAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.15), weight: 30),
TweenSequenceItem(tween: Tween(begin: 0.15, end: 0.0), weight: 70),
]).animate(_controller);
}
@override
void didUpdateWidget(DownloadSuccessOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.showSuccess && !_wasSuccess) {
_controller.forward(from: 0);
}
_wasSuccess = widget.showSuccess;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: _flashAnimation.value),
borderRadius: BorderRadius.circular(12),
),
child: child,
);
},
child: widget.child,
);
}
}
//
// 7. Badge Bump Animation scales the badge when count changes
//
/// Wraps a [Badge] child and plays a brief scale-bump whenever [count] changes.
class AnimatedBadge extends StatefulWidget {
final int count;
final Widget child;
const AnimatedBadge({super.key, required this.count, required this.child});
@override
State<AnimatedBadge> createState() => _AnimatedBadgeState();
}
class _AnimatedBadgeState extends State<AnimatedBadge>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
int _previousCount = 0;
@override
void initState() {
super.initState();
_previousCount = widget.count;
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scaleAnimation = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 40),
TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 60),
]).animate(_controller);
}
@override
void didUpdateWidget(AnimatedBadge oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.count != _previousCount && widget.count > _previousCount) {
_controller.forward(from: 0);
}
_previousCount = widget.count;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(scale: _scaleAnimation, child: widget.child);
}
}
//
// 8. Animated Removal Item fade + slide out when removed from a list
//
/// Build a removal animation for [AnimatedList] items.
/// Use as the `builder` callback in [AnimatedListState.removeItem].
Widget buildRemovalAnimation(Widget child, Animation<double> animation) {
return SizeTransition(
sizeFactor: CurvedAnimation(parent: animation, curve: Curves.easeInOut),
child: FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn),
child: child,
),
);
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More