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>
> [!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
@@ -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) |
| [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]
> **Star the repo** to get notified about all new releases directly from GitHub.
@@ -137,14 +137,13 @@ class DownloadService : Service() {
private fun startForegroundService() {
isRunning = true
// Acquire wake lock to prevent CPU sleep
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
WAKELOCK_TAG
).apply {
acquire(60 * 60 * 1000L) // 1 hour max
acquire(60 * 60 * 1000L)
}
val notification = buildNotification(0, 0)
@@ -27,6 +27,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import org.json.JSONTokener
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
@@ -129,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() {
)
companion object {
// Minimum API level we consider "safe" for Impeller (Android 10+)
private const val SAFE_API_FOR_IMPELLER = 29
// Known problematic GPU patterns (lowercase)
private val PROBLEMATIC_GPU_PATTERNS = listOf(
"adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm
"adreno (tm) 4", // Adreno 400 series - some have issues
"mali-4", // Mali-400 series - old ARM GPUs
"mali-t6", // Mali-T600 series
"mali-t7", // Mali-T700 series (some)
"powervr sgx", // PowerVR SGX series - old Imagination GPUs
"powervr ge8320", // PowerVR GE8320 - known issues
"gc1000", // Vivante GC1000
"gc2000", // Vivante GC2000
"adreno (tm) 3",
"adreno (tm) 4",
"mali-4",
"mali-t6",
"mali-t7",
"powervr sgx",
"powervr ge8320",
"gc1000",
"gc2000",
)
// Known problematic chipsets/hardware (lowercase)
private val PROBLEMATIC_CHIPSETS = listOf(
"mt6762", // MediaTek Helio P22 with PowerVR GE8320
"mt6765", // MediaTek Helio P35 with PowerVR GE8320
"mt8768", // MediaTek tablet chip
"mp0873", // MediaTek variant
"msm8974", // Snapdragon 800/801 with Adreno 330
"msm8226", // Snapdragon 400 with Adreno 305
"msm8926", // Snapdragon 400 with Adreno 305
"apq8084", // Snapdragon 805 (some issues)
"mt6762",
"mt6765",
"mt8768",
"mp0873",
"msm8974",
"msm8226",
"msm8926",
"apq8084",
)
// Known problematic device models (lowercase)
private val PROBLEMATIC_MODELS = listOf(
"sm-t220", // Samsung Tab A7 Lite
"sm-t225", // Samsung Tab A7 Lite LTE
"hammerhead", // Nexus 5 (Adreno 330)
"sm-t220",
"sm-t225",
"hammerhead",
)
/**
* Check if device should use Skia instead of Impeller.
@@ -173,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() {
val model = Build.MODEL.lowercase(Locale.ROOT)
val device = Build.DEVICE.lowercase(Locale.ROOT)
// 1. Check for explicitly problematic device models
for (problematicModel in PROBLEMATIC_MODELS) {
if (model.contains(problematicModel) || device.contains(problematicModel)) {
android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel")
@@ -181,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// 2. Check for problematic chipsets
for (chipset in PROBLEMATIC_CHIPSETS) {
if (hardware.contains(chipset) || board.contains(chipset)) {
android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset")
@@ -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) {
// For older Android, check GPU renderer if available
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
// Check for known problematic GPUs
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern")
@@ -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) {
android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety")
return true
}
}
// 4. For Android 10+, still check for known problematic GPUs
val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT)
for (pattern in PROBLEMATIC_GPU_PATTERNS) {
if (gpuRenderer.contains(pattern)) {
@@ -227,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() {
*/
private fun getGpuRenderer(): String {
return try {
// This might not work before GL context is created,
// but worth trying for additional detection
android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: ""
} catch (e: Exception) {
""
@@ -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) {
stopDownloadProgressStream()
downloadProgressEventSink = sink
@@ -425,7 +445,7 @@ class MainActivity: FlutterFragmentActivity() {
}
if (payload != lastDownloadProgressPayload) {
lastDownloadProgressPayload = payload
sink.success(payload)
sink.success(parseJsonPayload(payload))
}
} catch (e: Exception) {
android.util.Log.w(
@@ -457,7 +477,7 @@ class MainActivity: FlutterFragmentActivity() {
}
if (payload != lastLibraryScanProgressPayload) {
lastLibraryScanProgressPayload = payload
sink.success(payload)
sink.success(parseJsonPayload(payload))
}
} catch (e: Exception) {
android.util.Log.w(
@@ -599,7 +619,6 @@ class MainActivity: FlutterFragmentActivity() {
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
*/
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
// Try DISPLAY_NAME first
try {
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
@@ -610,7 +629,6 @@ class MainActivity: FlutterFragmentActivity() {
}
} catch (_: Exception) {}
// Try MIME_TYPE
try {
val mime = contentResolver.getType(uri)
val ext = extFromMimeType(mime)
@@ -836,8 +854,6 @@ class MainActivity: FlutterFragmentActivity() {
val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
// Check for existing file WITHOUT creating the directory first.
// This prevents empty folders from being created for duplicate downloads.
val existingDir = findDocumentDir(treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
@@ -852,7 +868,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Only create the directory now that we know we need to download
val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory")
@@ -875,7 +890,6 @@ class MainActivity: FlutterFragmentActivity() {
val respObj = JSONObject(response)
if (respObj.optBoolean("success", false)) {
// Extension providers write to a local temp path instead of the SAF FD.
// Copy the local file into the SAF document so it is not empty.
val goFilePath = respObj.optString("file_path", "")
if (goFilePath.isNotEmpty() &&
!goFilePath.startsWith("content://") &&
@@ -924,15 +938,10 @@ class MainActivity: FlutterFragmentActivity() {
try {
val docId = android.provider.DocumentsContract.getDocumentId(childUri)
if (docId.isNullOrEmpty()) return null
// Document IDs typically look like "primary:Music/Album/file.cue"
// Parent would be "primary:Music/Album"
val lastSlash = docId.lastIndexOf('/')
if (lastSlash <= 0) return null
val parentDocId = docId.substring(0, lastSlash)
// Build a tree document URI for the parent so it supports listing/findFile
val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri)
if (treeDocId.isNullOrEmpty()) return null
@@ -957,21 +966,17 @@ class MainActivity: FlutterFragmentActivity() {
val lines = File(cueTempPath).readLines()
for (line in lines) {
val trimmed = line.trim().let { l ->
// Strip BOM
if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l
}
if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) {
val rest = trimmed.substring(5).trim()
// Parse: "filename" TYPE or filename TYPE
val filename = if (rest.startsWith("\"")) {
val endQuote = rest.indexOf('"', 1)
if (endQuote > 0) rest.substring(1, endQuote) else rest
} else {
// Last word is the type, everything else is the filename
val parts = rest.split("\\s+".toRegex())
if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest
}
// Return just the filename (strip any path separators)
return filename.substringAfterLast("/").substringAfterLast("\\")
}
}
@@ -1056,7 +1061,6 @@ class MainActivity: FlutterFragmentActivity() {
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
// CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio
val cueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
@@ -1141,7 +1145,6 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
// --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio ---
val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir) in cueFiles) {
@@ -1180,10 +1183,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1197,7 +1198,6 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1240,14 +1240,12 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
return "[]"
}
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val pct = scanned.toDouble() / totalItems.toDouble() * 100.0
@@ -1326,7 +1324,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
// Parse existing files map: URI -> lastModified
val existingFiles = mutableMapOf<String, Long>()
try {
val obj = JSONObject(existingFilesJson)
@@ -1345,20 +1342,15 @@ class MainActivity: FlutterFragmentActivity() {
}
val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
// CUE files to scan: (cueDoc, parentDir, lastModified)
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>()
val cueFilesToScan = mutableListOf<Triple<DocumentFile, DocumentFile, Long>>()
// Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set
val unchangedCueFiles = mutableListOf<Pair<DocumentFile, DocumentFile>>()
val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
val safChildLookupCache = mutableMapOf<String, Map<String, DocumentFile>>()
var traversalErrors = 0
// Build a map of CUE base URIs -> existing virtual track URIs from the database.
// Virtual paths look like "content://...album.cue#track01".
// We need this to preserve virtual paths for unchanged CUE files.
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>() // cueUri -> [virtualPaths]
val existingCueVirtualPaths = mutableMapOf<String, MutableList<String>>()
for (key in existingFiles.keys) {
val hashIdx = key.indexOf("#track")
if (hashIdx > 0) {
@@ -1367,7 +1359,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Collect all files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "")
@@ -1423,8 +1414,6 @@ class MainActivity: FlutterFragmentActivity() {
}
queue.add(child to childPath)
} else if (child.isFile) {
// Mark file as present first so it cannot be mis-classified as removed
// when provider-specific metadata calls (e.g., lastModified) fail.
val uriStr = child.uri.toString()
currentUris.add(uriStr)
@@ -1436,18 +1425,15 @@ class MainActivity: FlutterFragmentActivity() {
child.lastModified()
} catch (_: Exception) { 0L }
// Check if any virtual track from this CUE exists with matching modTime
val virtualPaths = existingCueVirtualPaths[uriStr]
val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] }
if (existingModified != null && existingModified == lastModified) {
// CUE is unchanged — mark virtual paths as current so they aren't removed
unchangedCueFiles.add(child to dir)
for (vp in virtualPaths) {
currentUris.add(vp)
}
} else {
// CUE is new or modified — needs scanning
cueFilesToScan.add(Triple(child, dir, lastModified))
}
} else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) {
@@ -1458,7 +1444,6 @@ class MainActivity: FlutterFragmentActivity() {
existingModified ?: 0L
}
// Check if file is new or modified
if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified))
}
@@ -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 totalFiles = currentUris.size
val filesToProcess = audioFiles.size + cueFilesToScan.size
@@ -1503,7 +1487,6 @@ class MainActivity: FlutterFragmentActivity() {
var scanned = 0
var errors = traversalErrors
// --- CUE first pass: parse new/modified CUE sheets ---
val cueReferencedAudioUris = mutableSetOf<String>()
for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) {
@@ -1524,7 +1507,6 @@ class MainActivity: FlutterFragmentActivity() {
var tempCuePath: String? = null
var tempAudioPath: String? = null
try {
// Copy CUE to temp
tempCuePath = copyUriToTemp(cueDoc.uri, ".cue")
if (tempCuePath == null) {
errors++
@@ -1533,10 +1515,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Extract the audio filename from the CUE sheet text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Find the referenced audio file as a sibling in the same SAF directory
val audioDoc = resolveCueAudioSibling(
parentDir = parentDir,
cueName = cueName,
@@ -1551,10 +1531,8 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Mark this audio file so we skip it in the regular audio pass
cueReferencedAudioUris.add(audioDoc.uri.toString())
// Copy audio to same temp dir so Go can resolve it
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
@@ -1568,7 +1546,6 @@ class MainActivity: FlutterFragmentActivity() {
continue
}
// Rename temp audio to its original name so Go can find it by name
val renamedAudio = File(tempDir, audioName)
val tempAudioFile = File(tempAudioPath)
if (renamedAudio.absolutePath != tempAudioFile.absolutePath) {
@@ -1576,7 +1553,6 @@ class MainActivity: FlutterFragmentActivity() {
tempAudioPath = renamedAudio.absolutePath
}
// Call Go to produce library scan entries for each CUE track
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
tempCuePath,
tempDir,
@@ -1588,7 +1564,6 @@ class MainActivity: FlutterFragmentActivity() {
for (j in 0 until cueArray.length()) {
val trackObj = cueArray.getJSONObject(j)
results.put(trackObj)
// Register each virtual path as current so deletion detection works
val virtualPath = trackObj.optString("filePath", "")
if (virtualPath.isNotBlank()) {
currentUris.add(virtualPath)
@@ -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) {
var tempCue: String? = null
try {
@@ -1648,7 +1620,6 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// --- Regular audio file pass: skip files referenced by CUE sheets ---
for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) {
updateSafScanProgress { it.isComplete = true }
@@ -1661,7 +1632,6 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
// Skip audio files that are represented by CUE track entries
if (cueReferencedAudioUris.contains(doc.uri.toString())) {
scanned++
val processed = skippedCount + scanned
@@ -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) }
updateSafScanProgress {
@@ -1893,7 +1862,6 @@ class MainActivity: FlutterFragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// Update the intent so receive_sharing_intent can access the new data
setIntent(intent)
}
@@ -2000,13 +1968,13 @@ class MainActivity: FlutterFragmentActivity() {
val response = withContext(Dispatchers.IO) {
Gobackend.getDownloadProgress()
}
result.success(response)
result.success(parseJsonPayload(response))
}
"getAllDownloadProgress" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getAllDownloadProgress()
}
result.success(response)
result.success(parseJsonPayload(response))
}
"initItemProgress" -> {
val itemId = call.argument<String>("item_id") ?: ""
@@ -2553,7 +2521,6 @@ class MainActivity: FlutterFragmentActivity() {
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
// Replace file_path with temp path for Go
reqObj.put("file_path", tempPath)
val raw = Gobackend.reEnrichFile(reqObj.toString())
val obj = JSONObject(raw)
@@ -2631,7 +2598,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Deezer API methods
"searchDeezerAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2642,7 +2608,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Tidal search API
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2653,7 +2618,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Qobuz search API
"searchQobuzAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
@@ -2783,7 +2747,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Log methods
"getLogs" -> {
val response = withContext(Dispatchers.IO) {
Gobackend.getLogs()
@@ -2816,7 +2779,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension System methods
"initExtensionSystem" -> {
val extensionsDir = call.argument<String>("extensions_dir") ?: ""
val dataDir = call.argument<String>("data_dir") ?: ""
@@ -2961,7 +2923,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension Auth API methods
"getExtensionPendingAuth" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3011,7 +2972,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension FFmpeg API
"getPendingFFmpegCommand" -> {
val commandId = call.argument<String>("command_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3039,7 +2999,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Custom Search API
"customSearchWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val query = call.argument<String>("query") ?: ""
@@ -3055,7 +3014,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension URL Handler API
"handleURLWithExtension" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3100,7 +3058,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Post-Processing API
"runPostProcessing" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata") ?: ""
@@ -3144,7 +3101,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Extension Store
"initExtensionStore" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
@@ -3206,7 +3162,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
// Extension Home Feed (Explore)
"getExtensionHomeFeed" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val response = withContext(Dispatchers.IO) {
@@ -3221,7 +3176,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Local Library Scanning
"setLibraryCoverCacheDir" -> {
val cacheDir = call.argument<String>("cache_dir") ?: ""
withContext(Dispatchers.IO) {
@@ -3298,7 +3252,7 @@ class MainActivity: FlutterFragmentActivity() {
Gobackend.getLibraryScanProgressJSON()
}
}
result.success(response)
result.success(parseJsonPayload(response))
}
"cancelLibraryScan" -> {
withContext(Dispatchers.IO) {
@@ -3326,7 +3280,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// CUE Sheet Parsing
"parseCueSheet" -> {
val cuePath = call.argument<String>("cue_path") ?: ""
val audioDir = call.argument<String>("audio_dir") ?: ""
@@ -3338,17 +3291,14 @@ class MainActivity: FlutterFragmentActivity() {
?: return@withContext """{"error":"Failed to copy CUE file to temp"}"""
var tempAudioPath: String? = null
try {
// Extract audio filename from CUE text
val audioFileName = extractCueAudioFileName(tempCuePath)
// Try to find the audio sibling in SAF
var audioDoc: DocumentFile? = null
val parentDir = safParentDir(uri)
if (parentDir != null && !audioFileName.isNullOrBlank()) {
audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null }
}
// Fallback: try common extensions with the CUE base name
if (audioDoc == null && parentDir != null) {
val cueName = try {
DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: ""
@@ -3367,7 +3317,6 @@ class MainActivity: FlutterFragmentActivity() {
val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath
if (audioDoc != null) {
// Copy audio to same temp dir with original name
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null
@@ -3382,15 +3331,11 @@ class MainActivity: FlutterFragmentActivity() {
}
}
// Parse with audio in temp dir; Go will resolve there
val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir)
// Replace the temp audio_path with the SAF content:// URI
// so Dart knows it's a SAF file and handles it accordingly
if (audioDoc != null) {
val resultObj = JSONObject(resultJson)
resultObj.put("audio_path", audioDoc.uri.toString())
// Also pass the original CUE URI for reference
resultObj.put("cue_path", cuePath)
resultObj.toString()
} else {
+7 -1
View File
@@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) {
func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) {
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
case
"lyrics",
"lyric",
"unsyncedlyrics",
"unsynced lyrics",
"uslt",
"lrc":
return true
default:
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
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 {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
@@ -40,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
maxURL := upgradeToMaxQuality(downloadURL)
if maxURL != downloadURL {
downloadURL = maxURL
// Log already printed by upgradeToMaxQuality for Deezer
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
}
@@ -86,16 +87,22 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
}
func upgradeToMaxQuality(coverURL string) string {
// Spotify CDN upgrade
if strings.Contains(coverURL, spotifySize640) {
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
}
// Deezer CDN upgrade
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
return upgradeDeezerCover(coverURL)
}
if strings.Contains(coverURL, "resources.tidal.com") {
return upgradeTidalCover(coverURL)
}
if strings.Contains(coverURL, "static.qobuz.com") {
return upgradeQobuzCover(coverURL)
}
return coverURL
}
@@ -111,12 +118,35 @@ func upgradeDeezerCover(coverURL string) string {
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 {
if imageURL == "" {
return ""
}
// Always upgrade small to medium first
result := convertSmallToMedium(imageURL)
if maxQuality {
-10
View File
@@ -13,7 +13,6 @@ import (
// CueSheet represents a parsed .cue file
type CueSheet struct {
// Album-level metadata
Performer string `json:"performer"`
Title string `json:"title"`
FileName string `json:"file_name"`
@@ -32,7 +31,6 @@ type CueTrack struct {
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
// Index positions in seconds (fractional)
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
@@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// Handle BOM at start of file
if strings.HasPrefix(line, "\xef\xbb\xbf") {
line = strings.TrimPrefix(line, "\xef\xbb\xbf")
line = strings.TrimSpace(line)
@@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
upper := strings.ToUpper(line)
// REM commands (album-level metadata)
if strings.HasPrefix(upper, "REM ") {
matches := reRemCommand.FindStringSubmatch(line)
if len(matches) == 3 {
@@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
if strings.HasPrefix(upper, "FILE ") {
rest := line[len("FILE "):]
// Extract filename and type
// Format: FILE "filename.flac" WAVE
// or: FILE filename.flac WAVE
fname, ftype := parseCueFileLine(rest)
sheet.FileName = fname
sheet.FileType = ftype
@@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
}
if strings.HasPrefix(upper, "TRACK ") {
// Save previous track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
@@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
continue
}
// SONGWRITER (used as composer sometimes)
if strings.HasPrefix(upper, "SONGWRITER ") {
value := unquoteCue(line[len("SONGWRITER "):])
if currentTrack != nil {
@@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) {
}
}
// Don't forget the last track
if currentTrack != nil {
sheet.Tracks = append(sheet.Tracks, *currentTrack)
}
+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++ {
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)
time.Sleep(delay)
}
@@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa
lastErr = err
errStr := err.Error()
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
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)
}
// Try various response fields for download URL
for _, key := range []string{"download_url", "url", "link"} {
if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
+58 -126
View File
@@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) {
}
// SetSongLinkNetworkOptions is kept for backward compatibility.
// It now applies global network compatibility options for all backend API requests.
func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) {
SetNetworkCompatibilityOptions(allowHTTP, insecureTLS)
}
@@ -407,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) {
}
}
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:
return errorResponse("Unknown service: " + req.Service)
}
@@ -476,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) {
serviceNormalized := strings.ToLower(serviceRaw)
normalizedReq := req
if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) {
if isBuiltInProvider(serviceNormalized) {
normalizedReq.Service = serviceNormalized
}
@@ -486,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) {
}
normalizedJSON := string(normalizedBytes)
if serviceNormalized == "youtube" {
return DownloadFromYouTube(normalizedJSON)
}
if req.UseExtensions {
// Respect strict mode when auto fallback is disabled:
// for built-in providers, route directly to selected service only.
@@ -721,29 +698,57 @@ func ReadFileMetadata(filePath string) (string, error) {
if isFlac {
metadata, err := ReadMetadata(filePath)
if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
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
// File may have wrong extension (e.g. opus saved as .flac).
// Try Ogg/Opus parser as fallback before giving up.
GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err)
oggMeta, oggErr := ReadOggVorbisComments(filePath)
if oggErr == nil && oggMeta != nil {
result["title"] = oggMeta.Title
result["artist"] = oggMeta.Artist
result["album"] = oggMeta.Album
result["album_artist"] = oggMeta.AlbumArtist
result["date"] = oggMeta.Date
if oggMeta.Date == "" {
result["date"] = oggMeta.Year
}
result["track_number"] = oggMeta.TrackNumber
result["disc_number"] = oggMeta.DiscNumber
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)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
}
} else if isM4A {
@@ -910,7 +915,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{
"success": true,
"method": "ffmpeg",
@@ -1670,62 +1674,6 @@ func errorResponse(msg string) (string, error) {
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 {
if coverURL == "" {
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",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
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{}{
"track_name": req.TrackName,
"artist_name": req.ArtistName,
@@ -2187,12 +2133,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
return "", err
}
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
result := map[string]interface{}{
"id": ext.ID,
"name": ext.Manifest.Name,
@@ -2226,12 +2166,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) {
return "", err
}
settingsStore := GetExtensionSettingsStore()
settings := settingsStore.GetAll(ext.ID)
if len(settings) > 0 {
manager.InitializeExtension(ext.ID, settings)
}
result := map[string]interface{}{
"id": ext.ID,
"display_name": ext.Manifest.DisplayName,
@@ -3226,11 +3160,7 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) {
return "", fmt.Errorf("extension store not initialized")
}
if forceRefresh {
store.FetchRegistry(true)
}
extensions, err := store.GetExtensionsWithStatus()
extensions, err := store.getExtensionsWithStatus(forceRefresh)
if err != nil {
return "", err
}
@@ -3324,12 +3254,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
if !ext.Enabled {
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
// to avoid races with other provider calls (e.g. getAlbum/getPlaylist).
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
@@ -3339,7 +3271,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du
})()
`, functionName, functionName)
result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout)
result, err := RunWithTimeoutAndRecover(vm, script, timeout)
if err != nil {
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 {
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
SourceDir string `json:"source_dir"`
IconPath string `json:"icon_path"`
ID string `json:"id"`
Manifest *ExtensionManifest `json:"manifest"`
VM *goja.Runtime `json:"-"`
VMMu sync.Mutex `json:"-"`
runtime *ExtensionRuntime
initialized bool
Enabled bool `json:"enabled"`
Error string `json:"error,omitempty"`
DataDir string `json:"data_dir"`
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 {
@@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
SourceDir: extDir,
}
if err := m.initializeVM(ext); err != nil {
if err := validateExtensionLoad(ext); err != nil {
ext.Error = err.Error()
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
@@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
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()
ext.VM = vm
@@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
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 {
m.mu.Lock()
defer m.mu.Unlock()
@@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error {
return fmt.Errorf("Extension not found")
}
if ext.VM != nil {
cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null")
if err != nil {
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
}
ext.VMMu.Lock()
teardownVMLocked(ext)
ext.VMMu.Unlock()
delete(m.extensions, 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")
}
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])
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.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
@@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
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.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()
@@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
return fmt.Errorf("Extension not found")
}
if ext.VM == nil {
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
}
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
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", extensionID, err)
if err := ensureRuntimeReadyLocked(ext, false); 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
}
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
return initializeExtensionWithSettingsLocked(ext, settings)
}
func (m *ExtensionManager) CleanupExtension(extensionID string) error {
@@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
if ext.VM == nil {
return 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 {
ext.VMMu.Lock()
defer ext.VMMu.Unlock()
if err := runCleanupLocked(ext); err != nil {
GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, 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)
return nil
}
@@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (
return nil, fmt.Errorf("extension not found: %s", extensionID)
}
if ext.VM == nil {
return nil, fmt.Errorf("extension VM not initialized")
if err := ext.ensureRuntimeReady(); err != nil {
return nil, err
}
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) {
if !p.extension.Manifest.IsMetadataProvider() {
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 {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra
if !p.extension.Enabled {
return track, nil
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err)
return track, nil
}
defer p.extension.VMMu.Unlock()
trackJSON, err := json.Marshal(track)
@@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -501,8 +518,13 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string,
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return &ExtDownloadResult{
Success: false,
ErrorMessage: err.Error(),
ErrorType: "init_error",
}, nil
}
defer p.extension.VMMu.Unlock()
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 {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
if options == nil {
@@ -1707,8 +1730,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
script := fmt.Sprintf(`
@@ -1792,8 +1816,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
sourceJSON, _ := json.Marshal(sourceTrack)
@@ -1862,8 +1887,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return &PostProcessResult{Success: false, Error: err.Error()}, nil
}
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
@@ -1924,8 +1950,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return &PostProcessResult{Success: false, Error: err.Error()}, nil
}
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
@@ -2182,8 +2209,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
if err := p.lockReadyVM(); err != nil {
return nil, err
}
defer p.extension.VMMu.Unlock()
// 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"`
}
func (e *StoreExtension) ToResponse() StoreExtensionResponse {
return StoreExtensionResponse{
func (e *StoreExtension) ToResponse() *StoreExtensionResponse {
return &StoreExtensionResponse{
ID: e.ID,
Name: e.Name,
DisplayName: e.getDisplayName(),
@@ -236,7 +236,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
// Check if a registry URL has been configured
if s.registryURL == "" {
return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first")
}
@@ -289,8 +288,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error
return &registry, nil
}
func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(false)
func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) {
registry, err := s.FetchRegistry(forceRefresh)
if err != nil {
return nil, err
}
@@ -304,22 +303,29 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er
}
}
result := make([]StoreExtensionResponse, len(registry.Extensions))
for i, ext := range registry.Extensions {
resp := ext.ToResponse()
LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed))
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 {
resp.IsInstalled = true
resp.InstalledVersion = installedVersion
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
}
func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) {
return s.getExtensionsWithStatus(false)
}
func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error {
registry, err := s.FetchRegistry(false)
if err != nil {
@@ -389,7 +395,6 @@ func ResolveRegistryURL(input string) (string, error) {
return input, nil
}
// Try to match https://github.com/<owner>/<repo>[/...]
const ghPrefix = "https://github.com/"
if !strings.HasPrefix(input, ghPrefix) {
// Also accept http:// and upgrade silently.
@@ -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()
if err != nil {
return nil, err
@@ -480,7 +485,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
return extensions, nil
}
var result []StoreExtensionResponse
result := make([]*StoreExtensionResponse, 0, len(extensions))
queryLower := toLower(query)
for _, ext := range extensions {
@@ -493,7 +498,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor
!containsIgnoreCase(ext.DisplayName, queryLower) &&
!containsIgnoreCase(ext.Description, queryLower) &&
!containsIgnoreCase(ext.Author, queryLower) {
// Check tags
found := false
for _, tag := range ext.Tags {
if containsIgnoreCase(tag, queryLower) {
+1 -1
View File
@@ -12,6 +12,7 @@ require (
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864
golang.org/x/net v0.50.0
golang.org/x/text v0.34.0
)
require (
@@ -24,6 +25,5 @@ require (
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.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
)
-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/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
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/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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/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/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/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/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
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/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/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/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/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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
}
// Check for ISP blocking via HTTP status codes
// Some ISPs return 403 or 451 when blocking content
if resp.StatusCode == 403 || resp.StatusCode == 451 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
bodyStr := strings.ToLower(string(body))
// Check if response looks like ISP blocking page
ispBlockingIndicators := []string{
"blocked", "forbidden", "access denied", "not available in your",
"restricted", "censored", "unavailable for legal", "blocked by",
@@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError {
return nil
}
// Returns true if ISP blocking was detected
func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool {
ispErr := IsISPBlocking(err, requestURL)
if ispErr != nil {
@@ -553,7 +549,6 @@ func extractDomain(rawURL string) string {
return "unknown"
}
// If ISP blocking is detected, returns a more descriptive error
func WrapErrorWithISPCheck(err error, requestURL string, tag string) error {
if err == nil {
return nil
-2
View File
@@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
resp, err := sharedClient.Do(req)
if err == nil {
// Check for Cloudflare challenge page (403 with specific markers)
if resp.StatusCode == 403 || resp.StatusCode == 503 {
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
@@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) {
return resp, nil
}
// Check if error might be TLS-related (Cloudflare blocking)
errStr := strings.ToLower(err.Error())
tlsRelated := strings.Contains(errStr, "tls") ||
strings.Contains(errStr, "handshake") ||
+1 -15
View File
@@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) {
continue
}
// Skip audio files that are referenced by a .cue sheet
// (they will be represented by the cue sheet's track entries instead)
if cueReferencedAudioFiles[filePath] {
GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath))
continue
@@ -557,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string,
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) {
existingFiles := make(map[string]int64)
if snapshotPath == "" {
@@ -637,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []libraryAudioFileInfo
skippedCount := 0
existingCueTrackModTimes := make(map[string]int64)
@@ -653,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// For .cue files, also check if any virtual path entries exist
if strings.ToLower(filepath.Ext(f.path)) == ".cue" {
if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks {
// CUE file exists in DB via virtual paths; check if modTime changed
if f.modTime == cueTrackModTime {
skippedCount++
} else {
@@ -675,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
var deletedPaths []string
for existingPath := range existingFiles {
// For CUE virtual paths (e.g. "/path/album.cue#track01"),
// check if the base .cue file still exists on disk
if idx := strings.LastIndex(existingPath, "#track"); idx > 0 {
baseCuePath := existingPath[:idx]
if currentPathSet[baseCuePath] {
continue // Base .cue file still exists, not deleted
continue
}
// Base CUE file is gone, mark virtual path as deleted
deletedPaths = append(deletedPaths, existingPath)
} else if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
@@ -713,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
// Track audio files referenced by .cue sheets to avoid duplicates (incremental)
cueReferencedAudioFilesInc := make(map[string]bool)
parsedCueFiles := make(map[string]scannedCueFileInfo)
for _, f := range filesToScan {
@@ -748,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
ext := strings.ToLower(filepath.Ext(f.path))
// Handle .cue files: produce multiple track results
if ext == ".cue" {
var cueResults []LibraryScanResult
cueInfo, ok := parsedCueFiles[f.path]
@@ -773,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
continue
}
// Skip audio files referenced by .cue sheets
if cueReferencedAudioFilesInc[f.path] {
continue
}
-6
View File
@@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) {
return
}
// Validate provider names
validNames := map[string]bool{
LyricsProviderSpotifyAPI: true,
LyricsProviderLRCLIB: true,
@@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) {
GoLog("[Lyrics] Provider order set to: %v\n", valid)
}
// GetLyricsProviderOrder returns the current lyrics provider order.
func GetLyricsProviderOrder() []string {
lyricsProvidersMu.RLock()
defer lyricsProvidersMu.RUnlock()
@@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string {
return result
}
// GetAvailableLyricsProviders returns metadata about all available providers.
func GetAvailableLyricsProviders() []map[string]interface{} {
return []map[string]interface{}{
{"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
}
// SetLyricsFetchOptions sets provider-specific lyric fetch behavior.
func SetLyricsFetchOptions(opts LyricsFetchOptions) {
normalized := normalizeLyricsFetchOptions(opts)
@@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) {
)
}
// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior.
func GetLyricsFetchOptions() LyricsFetchOptions {
lyricsFetchOptionsMu.RLock()
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)
// Cascade through all configured built-in providers
for _, providerName := range providerOrder {
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)
}
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 {
if track == nil {
return ""
}
return qobuzFirstNonEmpty(
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
track.Album.Image.Large,
track.Album.Image.Small,
track.Album.Image.Thumbnail,
)
))
}
func qobuzAlbumImage(album *qobuzAlbumDetails) string {
if album == nil {
return ""
}
return qobuzFirstNonEmpty(
return qobuzUpscaleImageURL(qobuzFirstNonEmpty(
album.Image.Large,
album.Image.Small,
album.Image.Thumbnail,
)
))
}
func qobuzTrackArtistID(track *QobuzTrack) string {
@@ -936,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(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{
+5 -7
View File
@@ -1015,13 +1015,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay
tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items))
for _, item := range itemsModule.PagedList.Items {
track := item.Item
if track.Album.ID == 0 {
track.Album.ID = headerModule.Album.ID
track.Album.Title = headerModule.Album.Title
track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL
}
track.Album.ID = headerModule.Album.ID
track.Album.Title = headerModule.Album.Title
track.Album.Cover = headerModule.Album.Cover
track.Album.ReleaseDate = headerModule.Album.ReleaseDate
track.Album.URL = headerModule.Album.URL
tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track))
}
+1 -6
View File
@@ -24,11 +24,9 @@ func normalizeLooseTitle(title string) string {
b.WriteRune(r)
case unicode.IsSpace(r):
b.WriteByte(' ')
// Treat common separators as spaces.
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
// Drop other punctuation/symbols (including emoji) for loose matching.
}
}
@@ -59,7 +57,6 @@ func normalizeLooseArtistName(name string) string {
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
b.WriteByte(' ')
default:
// Drop remaining punctuation/symbols for loose artist matching.
}
}
@@ -102,13 +99,11 @@ func normalizeSymbolOnlyTitle(title string) string {
return b.String()
}
// ==================== Shared Track Verification ====================
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
Duration int // seconds
Duration int
}
// trackMatchesRequest checks whether a resolved track from a provider matches
-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
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.9.0';
static const String buildNumber = '115';
static const String version = '4.1.0';
static const String buildNumber = '117';
static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release.
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 mobileAuthor = 'zarzet';
+234 -18
View File
@@ -1432,6 +1432,66 @@ abstract class AppLocalizations {
/// **'Playlists'**
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
///
/// In en, this message translates to:
@@ -2662,24 +2722,6 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'**
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
///
/// In en, this message translates to:
@@ -2860,6 +2902,18 @@ abstract class AppLocalizations {
/// **'Artist/Album/ and Artist/Singles/'**
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
///
/// In en, this message translates to:
@@ -5084,6 +5138,168 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Empty only'**
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
+139 -10
View File
@@ -772,6 +772,36 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get tooltipPlay => 'Abspielen';
@@ -1449,16 +1479,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Qualität vor Download fragen';
@@ -1558,6 +1578,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'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
String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen';
@@ -2995,4 +3022,106 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
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
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,4 +2990,106 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
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
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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`).
@@ -4334,16 +4463,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Preguntar antes de descargar';
+139 -10
View File
@@ -761,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
String get tooltipPlay => 'Play';
@@ -1427,16 +1457,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1534,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2964,4 +2991,106 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
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
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2962,4 +2989,106 @@ class AppLocalizationsHi extends AppLocalizations {
@override
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
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
String get tooltipPlay => 'Putar';
@@ -1433,16 +1463,6 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1541,6 +1561,13 @@ class AppLocalizationsId extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artis/Album/ dan Artis/Single/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih';
@@ -2972,4 +2999,106 @@ class AppLocalizationsId extends AppLocalizations {
@override
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
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
String get tooltipPlay => '再生';
@@ -1414,16 +1444,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1519,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => '選択済みを削除';
@@ -2949,4 +2976,106 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
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
String get tooltipPlay => '재생';
@@ -1405,16 +1435,6 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1512,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2942,4 +2969,106 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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
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
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2962,4 +2989,106 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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
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
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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`).
@@ -4331,16 +4460,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar';
+139 -10
View File
@@ -773,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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
String get tooltipPlay => 'Воспроизвести';
@@ -1450,16 +1480,6 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube обеспечивает только звук с потерями(Lossy).';
@override
String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus';
@override
String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1561,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Исполнитель/Альбом и Исполнитель/Сингл/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Удалить выбранные';
@@ -3022,4 +3049,106 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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
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
String get tooltipPlay => 'Oynat';
@@ -1431,16 +1461,6 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1538,6 +1558,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2968,4 +2995,106 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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
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
String get tooltipPlay => 'Play';
@@ -1425,16 +1455,6 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1532,6 +1552,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get albumFolderArtistAlbumSinglesSubtitle =>
'Artist/Album/ and Artist/Singles/';
@override
String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)';
@override
String get albumFolderArtistAlbumFlatSubtitle =>
'Artist/Album/ and Artist/song.flac';
@override
String get downloadedAlbumDeleteSelected => 'Delete Selected';
@@ -2963,6 +2990,108 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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`).
@@ -4297,16 +4426,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -6703,16 +6822,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
String get qualityNote =>
'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
String get downloadAskBeforeDownload => 'Ask Before Download';
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
+172 -12
View File
@@ -999,6 +999,46 @@
"@searchPlaylists": {
"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": {
"description": "Tooltip - play button"
@@ -1869,18 +1909,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
@@ -2001,6 +2029,14 @@
"@albumFolderArtistAlbumSinglesSubtitle": {
"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": {
"description": "Button - delete selected tracks"
@@ -3903,5 +3939,129 @@
"editMetadataSelectEmpty": "Empty only",
"@editMetadataSelectEmpty": {
"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": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-12
View File
@@ -1773,18 +1773,6 @@
"@qualityNote": {
"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": {
"description": "Setting - show quality picker"
-3
View File
@@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
if (settings.localLibraryPath.isEmpty) return;
if (settings.localLibraryAutoScan == 'off') return;
// Don't start a scan if one is already running.
final libraryState = ref.read(localLibraryProvider);
if (libraryState.isScanning) return;
// Determine cooldown based on auto-scan mode.
final now = DateTime.now();
final prefs = await SharedPreferences.getInstance();
final lastScanned = readLocalLibraryLastScannedAt(prefs);
@@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization>
}
}
// All checks passed -- start an incremental scan.
final iosBookmark = settings.localLibraryBookmark;
ref
.read(localLibraryProvider.notifier)
-10
View File
@@ -42,10 +42,6 @@ class AppSettings {
final String lyricsMode;
final String
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
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
@@ -121,8 +117,6 @@ class AppSettings {
this.locale = 'system',
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.youtubeOpusBitrate = 256,
this.youtubeMp3Bitrate = 320,
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
this.downloadNetworkMode = 'any',
@@ -189,8 +183,6 @@ class AppSettings {
String? locale,
String? lyricsMode,
String? tidalHighFormat,
int? youtubeOpusBitrate,
int? youtubeMp3Bitrate,
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
String? downloadNetworkMode,
@@ -257,8 +249,6 @@ class AppSettings {
locale: locale ?? this.locale,
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate,
youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
-4
View File
@@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
locale: json['locale'] as String? ?? 'system',
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
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,
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
@@ -125,8 +123,6 @@ Map<String, dynamic> _$AppSettingsToJson(
'locale': instance.locale,
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'youtubeOpusBitrate': instance.youtubeOpusBitrate,
'youtubeMp3Bitrate': instance.youtubeMp3Bitrate,
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
'downloadNetworkMode': instance.downloadNetworkMode,
+431 -217
View File
@@ -5,6 +5,7 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.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/settings.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -262,8 +263,14 @@ class DownloadHistoryState {
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
static const int _orphanCleanupMaxPerLaunch = 80;
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;
bool _isLoaded = false;
bool _isSafRepairInProgress = false;
@@ -320,20 +327,29 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
unawaited(
Future<void>.delayed(_startupMaintenanceDelay, () async {
try {
final prefs = await SharedPreferences.getInstance();
if (Platform.isAndroid) {
await _repairMissingSafEntries(
initialItems,
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;
if (currentItems.isNotEmpty) {
await _backfillAudioMetadata(
currentItems,
maxItems: _audioMetadataBackfillMaxPerLaunch,
prefs: prefs,
);
}
} 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) {
try {
final parsed = Uri.parse(uri);
@@ -357,6 +397,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
Future<void> _repairMissingSafEntries(
List<DownloadHistoryItem> items, {
required int maxItems,
required SharedPreferences prefs,
}) async {
if (_isSafRepairInProgress || items.isEmpty) {
return;
@@ -378,22 +419,40 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
continue;
}
candidateIndexes.add(i);
if (candidateIndexes.length >= maxItems) break;
}
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;
return;
}
final updatedItems = [...items];
final persistedUpdates = <Map<String, dynamic>>[];
var changed = false;
var repairedCount = 0;
var verifiedCount = 0;
try {
for (var c = 0; c < candidateIndexes.length; c++) {
final i = candidateIndexes[c];
for (var c = 0; c < selectedIndexes.length; c++) {
final i = selectedIndexes[c];
final item = items[i];
final rawPath = item.filePath.trim();
final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath);
@@ -408,7 +467,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
persistedUpdates.add(verified.toJson());
continue;
}
}
@@ -445,7 +504,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
persistedUpdates.add(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
@@ -456,11 +515,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
if (changed) {
await _db.upsertBatch(persistedUpdates);
state = state.copyWith(items: updatedItems);
_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 {
_isSafRepairInProgress = false;
}
@@ -556,6 +622,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
Future<void> _backfillAudioMetadata(
List<DownloadHistoryItem> items, {
required int maxItems,
required SharedPreferences prefs,
}) async {
if (_isAudioMetadataBackfillInProgress || items.isEmpty) {
return;
@@ -563,15 +630,40 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_isAudioMetadataBackfillInProgress = true;
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;
for (final item in items) {
if (refreshedCount >= maxItems) {
break;
}
if (!_shouldBackfillAudioMetadata(item)) {
continue;
}
for (final index in selectedIndexes) {
final item = items[index];
final probed = await _probeAudioMetadata(
item.filePath,
@@ -598,15 +690,29 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
continue;
}
await updateAudioMetadataForItem(
id: item.id,
final updated = item.copyWith(
quality: resolvedQuality,
bitDepth: resolvedBitDepth,
sampleRate: resolvedSampleRate,
);
updatedItems ??= [...items];
updatedItems[index] = updated;
persistedUpdates.add(updated.toJson());
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) {
_historyLog.i(
'Audio metadata backfill refreshed $refreshedCount items',
@@ -768,9 +874,6 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
await _db.upsert(updated.toJson());
}
/// Remove history entries where the file no longer exists on disk
/// Returns the number of orphaned entries removed
/// Audio file extensions that the app commonly produces or converts between.
static const _audioExtensions = [
'.flac',
'.m4a',
@@ -781,11 +884,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
'.aac',
];
/// When the original file is missing, check whether a sibling with a
/// different audio extension exists (e.g. the user converted .flac .opus).
/// Returns the path of the first match found, or `null` if none exist.
Future<String?> _findConvertedSibling(String originalPath) async {
// Strip the current extension to get the base path.
final dotIndex = originalPath.lastIndexOf('.');
if (dotIndex < 0) return null;
final basePath = originalPath.substring(0, dotIndex);
@@ -801,11 +900,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return null;
}
Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...');
final entries = await _db.getAllEntriesWithPaths();
Future<
({
List<String> orphanedIds,
Map<String, String> replacementPaths,
Map<String, String> pathById,
})
>
_inspectOrphanedEntries(List<Map<String, dynamic>> entries) async {
final orphanedIds = <String>[];
final replacementPaths = <String, String>{};
final pathById = <String, String>{};
const checkChunkSize = 16;
@@ -824,14 +928,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
try {
if (await fileExists(filePath)) return MapEntry(id, true);
// Original file missing -- check for a converted sibling.
final sibling = await _findConvertedSibling(filePath);
if (sibling != null) {
_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.
await _db.updateFilePath(id, sibling);
replacementPaths[id] = sibling;
pathById[id] = sibling;
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');
return 0;
}
final deletedCount = await _db.deleteByIds(orphanedIds);
final orphanedSet = orphanedIds.toSet();
state = state.copyWith(
items: state.items
.where((item) => !orphanedSet.contains(item.id))
.toList(),
final deletedCount = orphanedIds.isEmpty
? 0
: await _db.deleteByIds(orphanedIds);
_applyHistoryPathAndDeletionChanges(
deletedIds: orphanedIds,
replacementPaths: replacementPaths,
);
_historyLog.i('Cleaned up $deletedCount orphaned entries');
_historyLog.i(
'Cleaned up $deletedCount orphaned entries and repaired ${replacementPaths.length} paths',
);
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) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Singles folder');
@@ -1920,15 +2142,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
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') {
return '.m4a';
}
final q = quality.toLowerCase();
if (q.startsWith('opus')) return '.opus';
if (q.startsWith('mp3')) return '.mp3';
return '.flac';
}
@@ -2206,6 +2425,31 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_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() {
final items = state.items
.where(
@@ -2474,7 +2718,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
String _upgradeToMaxQualityCover(String coverUrl) {
// Spotify CDN upgrade (hash-based size identifiers)
const spotifySize300 = 'ab67616d00001e02';
const spotifySize640 = 'ab67616d0000b273';
const spotifySizeMax = 'ab67616d000082c1';
@@ -2487,7 +2730,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
result = result.replaceFirst(spotifySize640, spotifySizeMax);
}
// Deezer CDN upgrade (1000x1000 1800x1800)
if (result.contains('cdn-images.dzcdn.net')) {
final upgraded = result.replaceFirst(
_deezerSizeRegex,
@@ -3168,7 +3410,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Future<void> _processQueue() async {
if (state.isProcessing) return;
// Check network connectivity before starting
final settings = ref.read(settingsProvider);
updateSettings(settings);
final isSafMode = _isSafMode(settings);
@@ -3228,7 +3469,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(outputDir: musicDir.path);
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/)
_log.w(
'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
);
@@ -3250,7 +3490,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Output directory: ${state.outputDir}');
} else {
_log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})');
// Validate SAF permission is still accessible
try {
final testResult = await PlatformBridge.createSafFileFromPath(
treeUri: settings.downloadTreeUri,
@@ -3259,16 +3498,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
mimeType: 'application/octet-stream',
srcPath: '',
);
// If we got a result, permission is valid (file creation may fail but that's ok)
// If permission is revoked, this will throw
if (testResult != null) {
// Clean up test file
await PlatformBridge.safDelete(testResult);
}
} catch (e) {
_log.e('SAF permission validation failed: $e');
_log.w('SAF tree URI may be invalid or permission revoked');
// Mark all queued items as failed
for (final item in state.items) {
if (item.status == DownloadStatus.queued) {
updateItemStatus(
@@ -3402,8 +3637,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
if (activeDownloads.isNotEmpty) {
// Re-check queue/settings periodically so concurrency changes
// (e.g. 1 -> 3) can take effect before any active item finishes.
await Future.any([
Future.any(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
@@ -3555,28 +3788,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
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 relativeOutputDir = isSafMode
? 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 &&
deezerTrackId == null &&
!shouldSkipExtensionSongLinkPrelookup &&
trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) {
!trackToDownload.id.startsWith('extension:') &&
!trackToDownload.id.startsWith('tidal:') &&
!trackToDownload.id.startsWith('qobuz:')) {
try {
String spotifyId = trackToDownload.id;
if (spotifyId.startsWith('spotify:track:')) {
@@ -3703,7 +4002,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
'track',
spotifyId,
);
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
final trackData = deezerData['track'];
if (trackData is Map<String, dynamic>) {
final rawId = trackData['spotify_id'] as String?;
@@ -3839,14 +4137,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final relativeDir = useSaf ? outputDir : '';
final fileName = useSaf ? (safFileName ?? '') : '';
final outputExt = useSaf ? safOutputExt : '';
final isYouTube = item.service == 'youtube';
final shouldUseExtensions = !isYouTube && useExtensions;
final shouldUseFallback = !isYouTube && state.autoFallback;
final shouldUseExtensions = useExtensions;
final shouldUseFallback = state.autoFallback;
if (isYouTube) {
_log.d('Using YouTube/Cobalt provider for download');
_log.d('Quality: $quality (lossy only)');
} else if (shouldUseExtensions) {
if (shouldUseExtensions) {
_log.d('Using extension providers for download');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
@@ -4013,7 +4307,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
finalSafFileName = reportedFileName;
}
// Check if file already existed (detected via ISRC match in Go backend)
final wasExisting = result['already_exists'] == true;
if (wasExisting) {
_log.i('File already exists in library: $filePath');
@@ -4026,7 +4319,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String actualQuality = quality;
if (actualBitDepth != null && actualBitDepth > 0) {
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
? (actualSampleRate / 1000).toStringAsFixed(
actualSampleRate % 1000 == 0 ? 0 : 1,
@@ -4182,7 +4474,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
if (isM4aFile || shouldForceTidalSafM4aHandling) {
// At this point filePath is guaranteed non-null by the checks above.
final currentFilePath = filePath;
if (isContentUriPath && effectiveSafMode) {
@@ -4521,11 +4812,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else if (metadataEmbeddingEnabled &&
isContentUriPath &&
effectiveSafMode &&
isFlacFile &&
!isM4aFile &&
!wasExisting) {
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(
'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);
if (tempPath != null) {
@@ -4545,21 +4848,39 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
await _embedMetadataAndCover(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
writeExternalLrc: false,
);
if (isMp3File) {
await _embedMetadataToMp3(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
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(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt('.flac'),
mimeType: _mimeTypeForExt(ext),
srcPath: tempPath,
);
@@ -4569,12 +4890,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
filePath = newUri;
finalSafFileName = newFileName;
_log.d('SAF FLAC metadata embedding completed');
_log.d('SAF $formatName metadata embedding completed');
} 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) {
_log.w('SAF FLAC metadata embedding failed: $e');
_log.w('SAF $formatName metadata embedding failed: $e');
} finally {
try {
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);
if (itemAfterDownload == null ||
_isLocallyCancelled(item.id, item: itemAfterDownload)) {
@@ -4746,9 +4966,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
// SAF downloads should end with content URI. If we still have a
// transient FD path, recover URI from SAF metadata to keep history
// dedup/exclusion stable.
if (effectiveSafMode &&
filePath != null &&
filePath.isNotEmpty &&
@@ -5063,8 +5280,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
_failedInSession++;
// Immediately cleanup connections after failure to prevent
// poisoned connection pool from affecting subsequent downloads
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
@@ -5117,7 +5332,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
_failedInSession++;
// Immediately cleanup connections after exception
try {
await PlatformBridge.cleanupConnections();
} catch (cleanupErr) {
+1 -9
View File
@@ -11,7 +11,7 @@ final _log = AppLogger('ExploreProvider');
class ExploreItem {
final String id;
final String uri;
final String type; // track, album, playlist, artist, station
final String type;
final String name;
final String artists;
final String? description;
@@ -168,7 +168,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
return const ExploreState();
}
/// Restore cached home feed from SharedPreferences immediately on startup
Future<void> _restoreFromCache() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -199,7 +198,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
}
/// Save home feed to SharedPreferences for instant restore on next launch
Future<void> _saveToCache(List<ExploreSection> sections) async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -212,11 +210,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
}
}
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
@@ -230,7 +226,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
return;
}
// Only show loading spinner if we have no cached content to display
final showLoading = !state.hasContent;
state = state.copyWith(isLoading: showLoading, error: null);
@@ -247,14 +242,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
// If user has a preference, use that
if (preferredId != null &&
preferredId.isNotEmpty &&
extension.id == preferredId) {
targetExt = extension;
break;
}
// Otherwise take the first available (fallback to spotify-web if found)
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (preferredId == null && extension.id == 'spotify-web') {
@@ -317,7 +310,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
lastFetched: DateTime.now(),
);
// Save to disk cache for instant restore on next app launch
_saveToCache(sections);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
+7 -13
View File
@@ -32,14 +32,12 @@ class Extension {
final bool hasMetadataProvider;
final bool hasDownloadProvider;
final bool hasLyricsProvider;
final bool
skipMetadataEnrichment; // If true, use metadata from extension instead of enriching
final bool skipMetadataEnrichment;
final SearchBehavior? searchBehavior;
final URLHandler? urlHandler;
final TrackMatching? trackMatching;
final PostProcessing? postProcessing;
final Map<String, dynamic>
capabilities; // Extension capabilities (homeFeed, browseCategories, etc.)
final Map<String, dynamic> capabilities;
const Extension({
required this.id,
@@ -198,12 +196,10 @@ class SearchBehavior {
final String? placeholder;
final bool primary;
final String? icon;
final String?
thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3)
final String? thumbnailRatio;
final int? thumbnailWidth;
final int? thumbnailHeight;
final List<SearchFilter>
filters; // Available search filters (e.g., track, album, artist, playlist)
final List<SearchFilter> filters;
const SearchBehavior({
required this.enabled,
@@ -239,11 +235,11 @@ class SearchBehavior {
}
switch (thumbnailRatio) {
case 'wide': // 16:9 - YouTube style
case 'wide':
return (defaultSize * 16 / 9, defaultSize);
case 'portrait': // 2:3 - Poster style
case 'portrait':
return (defaultSize * 2 / 3, defaultSize);
case 'square': // 1:1 - Album art style
case 'square':
default:
return (defaultSize, defaultSize);
}
@@ -290,7 +286,6 @@ class PostProcessing {
}
}
/// URL handler configuration for custom URL patterns
class URLHandler {
final bool enabled;
final List<String> patterns;
@@ -304,7 +299,6 @@ class URLHandler {
);
}
/// Check if a URL matches any of the patterns
bool matchesURL(String url) {
if (!enabled || patterns.isEmpty) return false;
final lowerUrl = url.toLowerCase();
@@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final destPath = p.join(coversDir.path, '$playlistId$ext');
if (playlist.coverImagePath == destPath) return;
// Copy image to persistent location
await File(sourceFilePath).copy(destPath);
final now = DateTime.now();
@@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier<LibraryCollectionsState> {
final playlist = state.playlistById(playlistId);
if (playlist == null || playlist.coverImagePath == null) return;
// Delete the file if it exists
final path = playlist.coverImagePath;
if (path != null) {
final file = File(path);
+4 -28
View File
@@ -252,8 +252,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling();
// On iOS, start accessing the security-scoped bookmark so the Go backend
// can read files outside the app sandbox.
String? resolvedPath;
bool didStartSecurityAccess = false;
if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) {
@@ -275,9 +273,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final isSaf = effectiveFolderPath.startsWith('content://');
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
// been flushed to SQLite yet.
final downloadedPaths = await _historyDb.getAllFilePaths();
final inMemoryHistoryPaths = ref
.read(downloadHistoryProvider)
@@ -298,7 +293,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf
? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
@@ -324,16 +318,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Skipped $skippedDownloads files already in download history');
}
// Full scan should replace library index entirely.
await _db.clearAll();
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);
await _db.replaceAll(items.map((e) => e.toJson()).toList());
final persistedItems = [...items]..sort(_compareLibraryItems);
final now = DateTime.now();
try {
@@ -364,7 +350,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
errorCount: state.scanErrorCount,
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
_log.i(
'Incremental scan: ${existingFiles.length} existing files in database',
@@ -423,7 +408,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return;
}
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList =
(result['files'] as List<dynamic>?) ??
(result['scanned'] as List<dynamic>?) ??
@@ -444,10 +428,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
);
// Build the incremental merge base from SQLite, not the current
// provider state. Startup auto-scan can fire before `state.items` has
// finished loading, which would otherwise drop unchanged rows from the
// in-memory library until a manual full rescan.
final existingJson = await _db.getAll();
final currentByPath = <String, LocalLibraryItem>{
for (final item in existingJson.map(LocalLibraryItem.fromJson))
@@ -468,7 +448,6 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
}
// Upsert new/modified items (excluding downloaded files)
final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = existingDownloadedPaths.length;
if (scannedList.isNotEmpty) {
@@ -502,11 +481,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Deleted $deleteCount items from database');
}
final items =
(await _db.getAll())
.map(LocalLibraryItem.fromJson)
.toList(growable: false)
..sort(_compareLibraryItems);
final items = currentByPath.values.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now();
try {
+2 -16
View File
@@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart';
const _maxRecentItems = 20;
/// Types of items that can be accessed
enum RecentAccessType { artist, album, track, playlist }
/// Represents a recently accessed item
class RecentAccessItem {
final String id;
final String name;
final String? subtitle; // Artist name for tracks/albums, null for artists
final String? subtitle;
final String? imageUrl;
final RecentAccessType type;
final DateTime accessedAt;
final String? providerId; // Extension ID or 'deezer' for built-in
final String? providerId;
const RecentAccessItem({
required this.id,
@@ -53,7 +51,6 @@ class RecentAccessItem {
);
}
/// Create a unique key for deduplication
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
@override
@@ -67,7 +64,6 @@ class RecentAccessItem {
int get hashCode => uniqueKey.hashCode;
}
/// State for recent access history
class RecentAccessState {
final List<RecentAccessItem> items;
final Set<String> hiddenDownloadIds;
@@ -92,7 +88,6 @@ class RecentAccessState {
}
}
/// Provider for managing recent access history
class RecentAccessNotifier extends Notifier<RecentAccessState> {
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
@@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
}
/// Record an access to an artist
void recordArtistAccess({
required String id,
required String name,
@@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to an album
void recordAlbumAccess({
required String id,
required String name,
@@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to a track
void recordTrackAccess({
required String id,
required String name,
@@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
);
}
/// Record an access to a playlist
void recordPlaylistAccess({
required String id,
required String name,
@@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
}
}
/// Remove a specific item from history
void removeItem(RecentAccessItem item) {
final updatedItems = state.items
.where((e) => e.uniqueKey != item.uniqueKey)
@@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier<RecentAccessState> {
unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey));
}
/// Hide a download item from recents (without deleting the actual download)
void hideDownloadFromRecents(String downloadId) {
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
state = state.copyWith(hiddenDownloadIds: updatedHidden);
unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId));
}
/// Check if a download is hidden from recents
bool isDownloadHidden(String downloadId) {
return state.hiddenDownloadIds.contains(downloadId);
}
/// Clear all history
void clearHistory() {
state = state.copyWith(items: []);
unawaited(_appStateDb.clearRecentAccessRows());
}
/// Clear hidden downloads (show all again)
void clearHiddenDownloads() {
state = state.copyWith(hiddenDownloadIds: {});
unawaited(_appStateDb.clearHiddenRecentDownloadIds());
+5 -59
View File
@@ -11,13 +11,11 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 6;
const _currentMigrationVersion = 7;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
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}$');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -40,7 +38,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeYouTubeBitratesIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
@@ -122,6 +119,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
);
}
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 _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 {
if (!Platform.isIOS) return;
@@ -469,18 +427,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
_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) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
+77 -34
View File
@@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url';
int compareVersions(String v1, String v2) {
final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.');
final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.');
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
for (var i = 0; i < maxLen; i++) {
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
if (n1 < n2) return -1;
if (n1 > n2) return 1;
}
@@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) {
}
class StoreCategory {
static const String metadata = 'metadata';
static const String download = 'download';
static const String utility = 'utility';
static const String lyrics = 'lyrics';
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) {
switch (category) {
@@ -94,7 +99,8 @@ class StoreExtension {
return StoreExtension(
id: json['id'] 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',
author: json['author'] as String? ?? 'Unknown',
description: json['description'] as String? ?? '',
@@ -117,7 +123,6 @@ class StoreExtension {
}
}
class StoreState {
final List<StoreExtension> extensions;
final String? selectedCategory;
@@ -160,11 +165,15 @@ class StoreState {
}) {
return StoreState(
extensions: extensions ?? this.extensions,
selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory),
selectedCategory: clearCategory
? null
: (selectedCategory ?? this.selectedCategory),
searchQuery: searchQuery ?? this.searchQuery,
isLoading: isLoading ?? this.isLoading,
isDownloading: isDownloading ?? this.isDownloading,
downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId),
downloadingId: clearDownloadingId
? null
: (downloadingId ?? this.downloadingId),
error: clearError ? null : (error ?? this.error),
isInitialized: isInitialized ?? this.isInitialized,
registryUrl: registryUrl ?? this.registryUrl,
@@ -180,13 +189,16 @@ class StoreState {
if (searchQuery.isNotEmpty) {
final query = searchQuery.toLowerCase();
result = result.where((e) =>
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query))
).toList();
result = result
.where(
(e) =>
e.name.toLowerCase().contains(query) ||
e.displayName.toLowerCase().contains(query) ||
e.description.toLowerCase().contains(query) ||
e.author.toLowerCase().contains(query) ||
e.tags.any((t) => t.toLowerCase().contains(query)),
)
.toList();
}
return result;
@@ -206,23 +218,28 @@ class StoreNotifier extends Notifier<StoreState> {
Future<void> initialize(String cacheDir) async {
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 {
await PlatformBridge.initExtensionStore(cacheDir);
// Load saved registry URL from SharedPreferences
final prefs = await SharedPreferences.getInstance();
final savedUrl = prefs.getString(_registryUrlPrefKey) ?? '';
if (savedUrl.isNotEmpty) {
await PlatformBridge.setStoreRegistryUrl(savedUrl);
state = state.copyWith(registryUrl: savedUrl);
await refresh();
}
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) {
_log.e('Failed to initialize store: $e');
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).
final resolvedUrl = await PlatformBridge.getStoreRegistryUrl();
// Persist to SharedPreferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_registryUrlPrefKey, resolvedUrl);
state = state.copyWith(
registryUrl: resolvedUrl,
extensions: const [], // Clear old extensions
extensions: const [],
);
_log.i('Registry URL set to: $resolvedUrl');
@@ -292,7 +308,9 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(isLoading: true, clearError: true);
try {
final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh);
final extensions = await PlatformBridge.getStoreExtensions(
forceRefresh: forceRefresh,
);
state = state.copyWith(
extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(),
isLoading: false,
@@ -320,12 +338,23 @@ class StoreNotifier extends Notifier<StoreState> {
state = state.copyWith(searchQuery: '', clearCategory: true);
}
Future<bool> installExtension(String extensionId, String tempDir, String extensionsDir) async {
state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true);
Future<bool> installExtension(
String extensionId,
String tempDir,
String extensionsDir,
) async {
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
clearError: true,
);
try {
_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');
final extNotifier = ref.read(extensionProvider.notifier);
@@ -340,18 +369,28 @@ class StoreNotifier extends Notifier<StoreState> {
return success;
} catch (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;
}
}
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 {
_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');
final extNotifier = ref.read(extensionProvider.notifier);
@@ -366,7 +405,11 @@ class StoreNotifier extends Notifier<StoreState> {
return success;
} catch (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;
}
}
-2
View File
@@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await _saveToStorage();
}
/// Set custom seed color (used when dynamic color is disabled)
Future<void> setSeedColor(Color color) async {
state = state.copyWith(seedColorValue: color.toARGB32());
await _saveToStorage();
@@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
);
}
}
+19 -35
View File
@@ -18,21 +18,18 @@ class TrackState {
final String? artistId;
final String? artistName;
final String? coverUrl;
final String? headerImageUrl; // Artist header image for background
final String? headerImageUrl;
final int? monthlyListeners;
final List<ArtistAlbum>? artistAlbums; // For artist page
final List<Track>? artistTopTracks; // Artist's popular tracks
final List<SearchArtist>? searchArtists; // For search results
final List<SearchAlbum>? searchAlbums; // For search results (albums)
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode
final String?
searchExtensionId; // Extension ID used for current search results
final String?
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
final String?
searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz")
final List<ArtistAlbum>? artistAlbums;
final List<Track>? artistTopTracks;
final List<SearchArtist>? searchArtists;
final List<SearchAlbum>? searchAlbums;
final List<SearchPlaylist>? searchPlaylists;
final bool hasSearchText;
final bool isShowingRecentAccess;
final String? searchExtensionId;
final String? selectedSearchFilter;
final String? searchSource;
const TrackState({
this.tracks = const [],
@@ -127,9 +124,9 @@ class ArtistAlbum {
final String releaseDate;
final int totalTracks;
final String? coverUrl;
final String albumType; // album, single, compilation
final String albumType;
final String artists;
final String? providerId; // Extension ID if from extension
final String? providerId;
const ArtistAlbum({
required this.id,
@@ -204,7 +201,6 @@ class TrackNotifier extends Notifier<TrackState> {
return const TrackState();
}
/// Check if request is still valid (not cancelled by newer request)
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
@@ -217,7 +213,6 @@ class TrackNotifier extends Notifier<TrackState> {
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
// Retry logic for extension URL handlers (up to 3 attempts)
Map<String, dynamic>? result;
for (int attempt = 1; attempt <= 3; attempt++) {
result = await PlatformBridge.handleURLWithExtension(url);
@@ -541,7 +536,6 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
// If URL doesn't match any known service, it's unrecognized
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
@@ -643,7 +637,6 @@ class TrackNotifier extends Notifier<TrackState> {
}) async {
final requestId = ++_currentRequestId;
// Preserve selected filter during loading
final currentFilter = filterOverride ?? state.selectedSearchFilter;
state = TrackState(
@@ -662,7 +655,6 @@ class TrackNotifier extends Notifier<TrackState> {
final includeExtensions =
settings.useExtensionProviders && hasActiveMetadataExtensions;
// Determine the effective search provider
final effectiveProvider = builtInSearchProvider ?? 'deezer';
_log.i(
@@ -672,7 +664,6 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = [];
// Only use metadata providers for Deezer search (default behavior)
if (effectiveProvider == 'deezer') {
try {
_log.d('Calling metadata provider search API...');
@@ -692,7 +683,6 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Call the appropriate search API
switch (effectiveProvider) {
case 'tidal':
_log.d('Calling Tidal search API...');
@@ -808,9 +798,8 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter: currentFilter, // Preserve filter in results
searchSource:
effectiveProvider, // Track which service was used for search
selectedSearchFilter: currentFilter,
searchSource: effectiveProvider,
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -837,7 +826,7 @@ class TrackNotifier extends Notifier<TrackState> {
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
selectedSearchFilter:
state.selectedSearchFilter, // Preserve filter during loading
state.selectedSearchFilter,
);
try {
@@ -876,9 +865,8 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
hasSearchText: state.hasSearchText,
isShowingRecentAccess: state.isShowingRecentAccess,
searchExtensionId: extensionId, // Store which extension was used
selectedSearchFilter:
state.selectedSearchFilter, // Preserve selected filter
searchExtensionId: extensionId,
selectedSearchFilter: state.selectedSearchFilter,
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
@@ -934,7 +922,6 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (_) {
// Silently ignore update failures - track may have been removed
}
}
@@ -942,7 +929,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = const TrackState();
}
/// Set selected search filter for extension search
void setSearchFilter(String? filter) {
if (state.selectedSearchFilter == filter) return;
state = state.copyWith(
@@ -951,7 +937,6 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
/// Set search text state for back button handling
void setSearchText(bool hasText) {
if (state.hasSearchText == hasText) {
return;
@@ -966,7 +951,6 @@ class TrackNotifier extends Notifier<TrackState> {
state = state.copyWith(isShowingRecentAccess: showing);
}
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({
required List<Track> tracks,
String? albumName,
@@ -1127,7 +1111,7 @@ class TrackNotifier extends Notifier<TrackState> {
'isrc': isrc,
'track_name': track.name,
'artist_name': track.artistName,
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
'spotify_id': track.id,
'service': 'tidal',
});
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
+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/widgets/track_collection_quick_actions.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/widgets/playlist_picker_sheet.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
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -257,8 +268,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (_isLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
padding: EdgeInsets.all(16),
child: AlbumTrackListSkeleton(itemCount: 10),
),
),
if (_error != null)
@@ -534,9 +545,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
child: StaggeredListItem(
index: index,
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
),
);
}, childCount: tracks.length),
@@ -551,6 +565,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -576,7 +591,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final tracks = _tracks;
if (tracks == null || tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
@@ -623,6 +637,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
context,
trackName: '${tracksToQueue.length} tracks',
artistName: widget.albumName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
+28 -38
View File
@@ -20,6 +20,7 @@ import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen;
import 'package:spotiflac_android/widgets/download_service_picker.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';
class _ArtistCache {
@@ -152,6 +153,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
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
void initState() {
super.initState();
@@ -481,12 +492,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
hasDiscography: hasDiscography,
),
if (_isLoadingDiscography)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
const SliverToBoxAdapter(child: ArtistScreenSkeleton()),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
@@ -889,6 +895,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
_fetchAndQueueAlbums(albums, service, quality);
},
@@ -948,7 +955,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
fetchedCount++;
// Update progress dialog
if (mounted) {
_FetchingProgressDialog.updateProgress(
context,
@@ -979,7 +985,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return;
}
// Check which tracks are already downloaded
final historyState = ref.read(downloadHistoryProvider);
final tracksToQueue = <Track>[];
int skippedCount = 0;
@@ -1030,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
content: Text(message),
action: SnackBarAction(
label: context.l10n.snackbarViewQueue,
onPressed: () {
// Navigate to queue tab (index 1)
// This will be handled by the navigation system
},
onPressed: () {},
),
),
);
@@ -1154,6 +1156,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
imageUrl.isNotEmpty &&
Uri.tryParse(imageUrl)?.hasAuthority == true;
final isDark = Theme.of(context).brightness == Brightness.dark;
String? listenersText;
final listeners = _monthlyListeners ?? widget.monthlyListeners;
if (listeners != null && listeners > 0) {
@@ -1224,7 +1228,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
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],
),
@@ -1265,7 +1271,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
listenersText,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Colors.white.withValues(alpha: 0.8),
color: Colors.white,
shadows: [
Shadow(
offset: const Offset(0, 1),
@@ -1689,6 +1695,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
if (!mounted) return;
enqueue(service, quality: quality);
@@ -1839,29 +1846,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
Positioned(
top: 8,
right: 8,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 28,
height: 28,
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: colorScheme.surface.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
child: AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 28,
unselectedColor: colorScheme.surface.withValues(
alpha: 0.9,
),
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 18,
)
: null,
),
),
if (showTypeBadge)
@@ -2070,7 +2062,6 @@ class _FetchingProgressDialog extends StatefulWidget {
required this.onCancel,
});
// Static method to update progress from outside
static void updateProgress(BuildContext context, int current, int total) {
final state = context
.findAncestorStateOfType<_FetchingProgressDialogState>();
@@ -2143,7 +2134,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> {
),
),
const SizedBox(height: 8),
// Progress bar
ClipRRect(
borderRadius: BorderRadius.circular(4),
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/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
@@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final tracks =
allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
@@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
return itemKey == _albumLookupKey;
}).toList()..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
@@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!mounted) return;
final result = await navigator.push(
PageRouteBuilder(
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),
),
slidePageRoute(page: TrackMetadataScreen(item: item)),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
@@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
);
}, childCount: tracks.length),
);
@@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final discNumbers = _getSortedDiscNumbers(tracks);
final List<Widget> children = [];
var revealIndex = 0;
for (final discNumber in discNumbers) {
final discTracks = discMap[discNumber];
@@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children.add(
KeyedSubtree(
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,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? 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,
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
@@ -1123,7 +1105,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
? 'Opus'
: null;
if (ext == null || ext == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource = ext == 'FLAC' || ext == 'M4A';
if (isLosslessTarget && !isLosslessSource) continue;
+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/widgets/download_service_picker.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';
class HomeTab extends ConsumerStatefulWidget {
@@ -83,6 +84,18 @@ class _SearchResultBuckets {
});
}
enum _SearchSortOption {
defaultOrder,
titleAsc,
titleDesc,
artistAsc,
artistDesc,
durationAsc,
durationDesc,
dateAsc,
dateDesc,
}
const _homeHistoryPreviewLimit = 48;
class _HomeHistoryPreview {
@@ -244,6 +257,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Map<String, (double, double)>? _thumbnailSizesCache;
List<Track>? _searchBucketsSourceTracks;
_SearchResultBuckets? _searchBucketsCache;
_SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder;
double _responsiveScale({
required BuildContext context,
@@ -280,13 +294,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
double _exploreCardSize(BuildContext context) {
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
final textScale = _effectiveTextScale(context);
return 120 * scale * (1 + (textScale - 1) * 0.12);
return 145 * scale * (1 + (textScale - 1) * 0.12);
}
double _exploreSectionHeight(BuildContext context) {
final cardSize = _exploreCardSize(context);
final textScale = _effectiveTextScale(context);
return cardSize + 55 + ((textScale - 1) * 12);
return cardSize + 58 + ((textScale - 1) * 12);
}
@override
@@ -564,6 +578,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
'${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}';
if (_lastSearchQuery == searchKey) return;
_lastSearchQuery = searchKey;
_searchSortOption = _SearchSortOption.defaultOrder;
final isBuiltInProvider =
searchProvider != null &&
@@ -698,6 +713,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
playlistName: trackState.playlistName!,
coverUrl: trackState.coverUrl,
tracks: trackState.tracks,
recommendedService:
trackState.searchExtensionId ?? trackState.searchSource,
),
),
);
@@ -1281,8 +1298,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
exploreLoading)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 5),
),
),
@@ -1485,7 +1502,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
delegate: SliverChildBuilderDelegate((context, index) {
if (hasGreeting && index == 0) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
greeting,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
@@ -1500,7 +1517,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return _buildExploreSection(sections[sectionIndex], colorScheme);
}
return const SizedBox(height: 16);
return const SizedBox(height: 24);
}, childCount: totalCount),
),
];
@@ -1516,7 +1533,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
padding: const EdgeInsets.fromLTRB(16, 20, 16, 12),
child: Text(
section.title,
style: Theme.of(
@@ -1532,7 +1549,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
itemCount: section.items.length,
itemBuilder: (context, 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: [
ClipRRect(
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8,
isArtist ? cardSize / 2 : 10,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
@@ -1618,8 +1639,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: isArtist ? TextAlign.center : TextAlign.start,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
@@ -1632,7 +1653,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
fontSize: 12,
),
),
],
@@ -2122,7 +2143,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify') {
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
@@ -2162,7 +2185,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
} else if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify') {
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
@@ -2210,7 +2235,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (item.providerId != null &&
item.providerId!.isNotEmpty &&
item.providerId != 'deezer' &&
item.providerId != 'spotify') {
item.providerId != 'spotify' &&
item.providerId != 'tidal' &&
item.providerId != 'qobuz') {
Navigator.push(
context,
MaterialPageRoute(
@@ -2248,14 +2275,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
if (!mounted) return;
final result = await navigator.push(
PageRouteBuilder(
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),
),
slidePageRoute(page: TrackMetadataScreen(item: item)),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
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({
required List<Track> tracks,
required List<SearchArtist>? searchArtists,
@@ -2406,6 +2588,15 @@ class _HomeTabState extends ConsumerState<HomeTab>
required bool showLocalLibraryIndicator,
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) {
return [const SliverToBoxAdapter(child: SizedBox.shrink())];
}
@@ -2417,6 +2608,59 @@ class _HomeTabState extends ConsumerState<HomeTab>
final playlistItems = buckets.playlistItems;
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>[
if (error != null)
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(
_buildVirtualizedResultSection(
title: context.l10n.searchArtists,
itemCount: searchArtists.length,
itemCount: sortedArtists.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchArtistItemWidget(
key: ValueKey('search-artist-${searchArtists[index].id}'),
artist: searchArtists[index],
key: ValueKey('search-artist-${sortedArtists[index].id}'),
artist: sortedArtists[index],
showDivider: showDivider,
onTap: () => _navigateToArtist(
searchArtists[index].id,
searchArtists[index].name,
searchArtists[index].imageUrl,
sortedArtists[index].id,
sortedArtists[index].name,
sortedArtists[index].imageUrl,
),
),
),
);
sortButtonShown = true;
}
if (artistItems.isNotEmpty) {
@@ -2460,6 +2708,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchArtists,
itemCount: artistItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('artist-${artistItems[index].id}'),
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(
_buildVirtualizedResultSection(
title: context.l10n.searchAlbums,
itemCount: searchAlbums.length,
itemCount: sortedAlbums.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchAlbumItemWidget(
key: ValueKey('search-album-${searchAlbums[index].id}'),
album: searchAlbums[index],
key: ValueKey('search-album-${sortedAlbums[index].id}'),
album: sortedAlbums[index],
showDivider: showDivider,
onTap: () => _navigateToSearchAlbum(searchAlbums[index]),
onTap: () => _navigateToSearchAlbum(sortedAlbums[index]),
),
),
);
sortButtonShown = true;
}
if (albumItems.isNotEmpty) {
@@ -2492,6 +2744,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchAlbums,
itemCount: albumItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('album-${albumItems[index].id}'),
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(
_buildVirtualizedResultSection(
title: context.l10n.searchPlaylists,
itemCount: searchPlaylists.length,
itemCount: sortedPlaylists.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget(
key: ValueKey('search-playlist-${searchPlaylists[index].id}'),
playlist: searchPlaylists[index],
key: ValueKey('search-playlist-${sortedPlaylists[index].id}'),
playlist: sortedPlaylists[index],
showDivider: showDivider,
onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]),
onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]),
),
),
);
sortButtonShown = true;
}
if (playlistItems.isNotEmpty) {
@@ -2524,6 +2780,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
title: context.l10n.searchPlaylists,
itemCount: playlistItems.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _CollectionItemWidget(
key: ValueKey('playlist-${playlistItems[index].id}'),
item: playlistItems[index],
@@ -2532,20 +2789,22 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
);
sortButtonShown = true;
}
if (realTracks.isNotEmpty) {
if (sortedTracks.isNotEmpty) {
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchSongs,
itemCount: realTracks.length,
itemCount: sortedTracks.length,
colorScheme: colorScheme,
showSortButton: !sortButtonShown,
itemBuilder: (index, showDivider) => _TrackItemWithStatus(
key: ValueKey(realTracks[index].id),
track: realTracks[index],
index: realTrackIndexes[index],
key: ValueKey(sortedTracks[index].id),
track: sortedTracks[index],
index: sortedTrackIndexes[index],
showDivider: showDivider,
onDownload: () => _downloadTrack(realTrackIndexes[index]),
onDownload: () => _downloadTrack(sortedTrackIndexes[index]),
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -2563,6 +2822,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
required int itemCount,
required ColorScheme colorScheme,
required Widget Function(int index, bool showDivider) itemBuilder,
bool showSortButton = false,
}) {
final sectionColor = Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
@@ -2574,12 +2834,47 @@ class _HomeTabState extends ConsumerState<HomeTab>
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(
children: [
Expanded(
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) {
final isFirst = index == 0;
final isLast = index == itemCount - 1;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: sectionColor,
borderRadius: BorderRadius.vertical(
top: isFirst ? const Radius.circular(20) : Radius.zero,
bottom: isLast ? const Radius.circular(20) : Radius.zero,
return StaggeredListItem(
index: index,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: sectionColor,
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),
@@ -2793,7 +3091,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
if (searchProvider != null && searchProvider.isNotEmpty) {
// Check built-in providers first
if (searchProvider == 'tidal') {
return 'Search with Tidal...';
}
@@ -2835,16 +3132,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(null);
},
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) {
@@ -2859,24 +3146,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
_triggerSearchWithFilter(filter.id);
},
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
? Icon(
_getFilterIcon(filter.icon!),
size: 18,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
)
? Icon(_getFilterIcon(filter.icon!), size: 18)
: null,
),
);
@@ -2913,7 +3184,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (text.isEmpty || text.length < _minLiveSearchChars) return;
if (text.startsWith('http') || text.startsWith('spotify:')) return;
// Reset last search query to force new search
_lastSearchQuery = null;
_performSearch(text, filterOverride: filter);
}
@@ -2931,15 +3201,11 @@ class _HomeTabState extends ConsumerState<HomeTab>
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.5),
),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(28),
@@ -2987,6 +3253,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
onSubmitted: (_) => _onSearchSubmitted(),
onTapOutside: (_) {
FocusScope.of(context).unfocus();
},
);
}
@@ -3035,7 +3304,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
.firstOrNull;
}
// Check if current provider is a built-in provider (tidal/qobuz)
const builtInProviders = {'tidal', 'qobuz'};
final isBuiltInProvider =
currentProvider != null && builtInProviders.contains(currentProvider);
@@ -3115,7 +3383,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
],
),
),
// Built-in Tidal search option
PopupMenuItem<String>(
value: 'tidal',
child: Row(
@@ -3143,7 +3410,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
],
),
),
// Built-in Qobuz search option
PopupMenuItem<String>(
value: 'qobuz',
child: Row(
@@ -3966,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
// Extract artist info from album response
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?;
@@ -4024,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
if (_isLoading) {
return Scaffold(
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) {
return Scaffold(
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,
coverUrl: widget.coverUrl,
tracks: _tracks!,
recommendedService: widget.extensionId,
);
}
}
@@ -4349,7 +4618,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
if (_isLoading) {
return Scaffold(
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/screens/library_tracks_folder_screen.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';
class LibraryPlaylistsScreen extends ConsumerWidget {
@@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
_PlaylistOptionTile(
BottomSheetOptionTile(
icon: Icons.edit_outlined,
title: context.l10n.collectionRenamePlaylist,
onTap: () {
@@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
_PlaylistOptionTile(
BottomSheetOptionTile(
icon: Icons.image_outlined,
title: context.l10n.collectionPlaylistChangeCover,
onTap: () {
@@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget {
},
),
_PlaylistOptionTile(
BottomSheetOptionTile(
icon: Icons.delete_outline,
iconColor: colorScheme.error,
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/screens/track_metadata_screen.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/animation_utils.dart';
class LibraryTracksFolderScreen extends ConsumerStatefulWidget {
final LibraryTracksFolderMode mode;
@@ -272,7 +274,6 @@ class _LibraryTracksFolderScreenState
break;
}
// Stale selection cleanup
if (_isSelectionMode) {
final validKeys = entries.map((e) => e.key).toSet();
_selectedKeys.removeWhere((key) => !validKeys.contains(key));
@@ -348,20 +349,23 @@ class _LibraryTracksFolderScreenState
final isSelected = _selectedKeys.contains(entry.key);
return KeyedSubtree(
key: ValueKey(entry.key),
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
? () => _toggleSelection(entry.key)
: null,
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(entry.key),
child: StaggeredListItem(
index: index,
child: _CollectionTrackTile(
entry: entry,
mode: widget.mode,
playlistId: widget.playlistId,
localLibraryState: localState,
folderTracks: folderTracks,
isSelectionMode: _isSelectionMode,
isSelected: isSelected,
onTap: _isSelectionMode
? () => _toggleSelection(entry.key)
: null,
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(entry.key),
),
),
);
}, childCount: entries.length),
@@ -372,7 +376,6 @@ class _LibraryTracksFolderScreenState
],
),
// Selection bottom bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
@@ -1081,14 +1084,19 @@ class _CollectionTrackTile extends ConsumerWidget {
final track = entry.track;
final colorScheme = Theme.of(context).colorScheme;
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) {
if (state.isDownloaded(track.id)) return true;
final byId = state.getBySpotifyId(track.id);
if (byId != null) return byId;
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) {
return true;
if (isrc != null && isrc.isNotEmpty) {
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(
@@ -1096,17 +1104,26 @@ class _CollectionTrackTile extends ConsumerWidget {
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
),
);
final isInLocalLibrary = showLocalLibraryIndicator
final localItem = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
localLibraryProvider.select((state) {
final isrc = track.isrc?.trim();
if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc);
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(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -1124,43 +1141,51 @@ class _CollectionTrackTile extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? 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,
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
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,
HeroMode(
enabled: heroTag != null,
child: heroTag != null
? Hero(
tag: heroTag,
child: 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,
),
),
),
)
: 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),
),
// Add to playlist (hidden in wishlist unless already downloaded)
if (showAddToPlaylist)
_CollectionOptionTile(
BottomSheetOptionTile(
icon: Icons.playlist_add,
title: context.l10n.collectionAddToPlaylist,
onTap: () {
@@ -1401,8 +1425,7 @@ class _CollectionTrackTile extends ConsumerWidget {
},
),
// Remove from folder / playlist
_CollectionOptionTile(
BottomSheetOptionTile(
icon: Icons.remove_circle_outline,
iconColor: colorScheme.error,
title: mode == LibraryTracksFolderMode.playlist
@@ -1500,16 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget {
);
if (historyItem != null) {
await Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: historyItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
await Navigator.of(
context,
).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem)));
return;
}
@@ -1524,16 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget {
localItem ??= localState.findByTrackAndArtist(track.name, track.artistName);
if (localItem != null) {
await Navigator.of(context).push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(localItem: localItem),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
await Navigator.of(
context,
).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem)));
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 {
final IconData icon;
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/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class LocalAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
@@ -531,7 +532,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) return null;
final first = tracks.first;
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
@@ -543,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return '$fmt ${firstBitrate}kbps'.trim();
}
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null ||
first.bitDepth == 0 ||
first.sampleRate == null) {
@@ -630,7 +629,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final track = discTracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
child: StaggeredListItem(
index: index,
child: _buildTrackItem(context, colorScheme, track),
),
);
}, childCount: discTracks.length),
),
@@ -669,28 +671,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected
? 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,
AnimatedSelectionCheckbox(
visible: true,
selected: isSelected,
colorScheme: colorScheme,
size: 24,
),
const SizedBox(width: 12),
],
@@ -1382,7 +1367,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
}
if (currentFormat == null || currentFormat == targetFormat) continue;
// Skip lossy sources when target is lossless (pointless re-encoding)
final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC';
final isLosslessSource =
currentFormat == 'FLAC' || currentFormat == 'M4A';
@@ -1503,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: !isSaf, // Only delete original for regular files
deleteOriginal: !isSaf,
);
if (coverPath != null) {
@@ -1522,15 +1506,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
if (isSaf) {
// For SAF: derive the parent tree URI and relative dir from the content URI,
// then create new SAF file and delete old one
// Parse the SAF URI to get the tree document path:
// content://...tree/...document/.../oldName.flac
// We need tree URI and relative dir to create the new file
final uri = Uri.parse(item.filePath);
final pathSegments = uri.pathSegments;
// Try to find 'tree' and 'document' segments
String? treeUri;
String relativeDir = '';
String oldFileName = '';
+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/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('MainShell');
@@ -31,9 +32,11 @@ class MainShell extends ConsumerStatefulWidget {
ConsumerState<MainShell> createState() => _MainShellState();
}
class _MainShellState extends ConsumerState<MainShell> {
class _MainShellState extends ConsumerState<MainShell>
with SingleTickerProviderStateMixin {
int _currentIndex = 0;
late final PageController _pageController;
late final AnimationController _tabJumpTransitionController;
bool _hasCheckedUpdate = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
@@ -48,6 +51,11 @@ class _MainShellState extends ConsumerState<MainShell> {
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentIndex);
_tabJumpTransitionController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
value: 1,
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: false,
@@ -154,7 +162,6 @@ class _MainShellState extends ConsumerState<MainShell> {
if (!Platform.isAndroid) return;
final settings = ref.read(settingsProvider);
// Only show if user is still on legacy storage mode with a download dir set
if (settings.storageMode == 'saf') return;
if (settings.downloadDirectory.isEmpty) return;
@@ -229,6 +236,7 @@ class _MainShellState extends ConsumerState<MainShell> {
void dispose() {
_shareSubscription?.cancel();
_pageController.dispose();
_tabJumpTransitionController.dispose();
super.dispose();
}
@@ -251,7 +259,8 @@ class _MainShellState extends ConsumerState<MainShell> {
}
if (_currentIndex != index) {
final shouldResetHome = index == 0;
final previousIndex = _currentIndex;
final isNonAdjacentJump = (previousIndex - index).abs() > 1;
HapticFeedback.selectionClick();
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -262,19 +271,23 @@ class _MainShellState extends ConsumerState<MainShell> {
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
if (shouldResetHome) {
_resetHomeToMain();
// Jump directly when skipping intermediate tabs to avoid
// 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) {
final previousIndex = _currentIndex;
if (_currentIndex != index) {
setState(() => _currentIndex = index);
final showStore = ref.read(
@@ -285,9 +298,6 @@ class _MainShellState extends ConsumerState<MainShell> {
showStoreTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
if (index == 0 && previousIndex != 0) {
_resetHomeToMain();
}
}
}
@@ -451,32 +461,44 @@ class _MainShellState extends ConsumerState<MainShell> {
label: l10n.navHome,
),
NavigationDestination(
icon: Badge(
isLabelVisible: queueState > 0,
label: Text('$queueState'),
child: const Icon(Icons.library_music_outlined),
),
selectedIcon: SlidingIcon(
icon: AnimatedBadge(
count: queueState,
child: Badge(
isLabelVisible: queueState > 0,
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,
),
if (showStore)
NavigationDestination(
icon: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
),
selectedIcon: SwingIcon(
icon: AnimatedBadge(
count: storeUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
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,
@@ -504,15 +526,27 @@ class _MainShellState extends ConsumerState<MainShell> {
return true;
},
child: Scaffold(
body: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) => _KeepAliveTabPage(
key: ValueKey('page-$index'),
child: tabs[index],
body: AnimatedBuilder(
animation: _tabJumpTransitionController,
child: PageView.builder(
controller: _pageController,
itemCount: tabs.length,
onPageChanged: _onPageChanged,
physics: const NeverScrollableScrollPhysics(),
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(
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.1, end: 0.05), weight: 20),
TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20),
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
]).animate(_controller);
_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/playlist_picker_sheet.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
final String? playlistId;
final String? recommendedService;
const PlaylistScreen({
super.key,
@@ -28,6 +30,7 @@ class PlaylistScreen extends ConsumerStatefulWidget {
this.coverUrl,
required this.tracks,
this.playlistId,
this.recommendedService,
});
@override
@@ -47,6 +50,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
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
void initState() {
super.initState();
@@ -360,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
if (_isLoading) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 8),
),
);
}
@@ -411,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final track = _tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
child: StaggeredListItem(
index: index,
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
),
);
}, childCount: _tracks.length),
@@ -429,6 +460,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
trackName: track.name,
artistName: track.artistName,
coverUrl: track.coverUrl,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -616,7 +648,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void _downloadTracks(BuildContext context, List<Track> tracks) {
if (tracks.isEmpty) return;
// Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider);
final localLibState =
@@ -663,6 +694,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
context,
trackName: '${tracksToQueue.length} tracks',
artistName: _playlistName,
recommendedService: _recommendedDownloadService(),
onSelect: (quality, service) {
ref
.read(downloadQueueProvider.notifier)
@@ -725,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
}),
);
// Check local library for duplicate detection
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(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/settings_provider.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';
class SearchScreen extends ConsumerStatefulWidget {
@@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
@override
@@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
Expanded(
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
child: AnimatedStateSwitcher(
child: isLoading && tracks.isEmpty
? const TrackListSkeleton(key: ValueKey('loading'))
: tracks.isEmpty
? _buildEmptyState(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) {
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
final coverWidget = track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
),
)
: 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),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
+15 -183
View File
@@ -477,122 +477,40 @@ class _CryptoWalletItem extends StatelessWidget {
}
}
int _cr(String v) {
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 {
class _SupporterChip extends StatelessWidget {
final String name;
final ColorScheme 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
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(
color: effectiveChipColor,
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
child: Container(
decoration: isGold
? BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: accentColor.withValues(alpha: 0.4),
width: 1,
),
)
: null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 10,
backgroundColor: accentColor.withValues(alpha: 0.2),
child: isGold
? Icon(Icons.star_rounded, size: 12, color: accentColor)
: Text(
widget.name.isNotEmpty
? widget.name[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: accentColor,
),
),
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
),
const SizedBox(width: 8),
Text(
widget.name,
name,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: isGold
? accentColor
: widget.colorScheme.onSecondaryContainer,
fontWeight: isGold ? FontWeight.w600 : FontWeight.w500,
color: colorScheme.onSecondaryContainer,
fontWeight: 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 {
+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/';
case 'artist_album_singles':
return 'Artist/Album/ + Artist/Singles/';
case 'artist_album_flat':
return 'Artist/Album/ + Artist/song.flac';
default:
return 'Albums/Artist/Album Name/';
}
@@ -958,6 +932,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
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(
BuildContext context,
WidgetRef ref,
@@ -2100,7 +2026,7 @@ class _ServiceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube'];
final builtInServiceIds = ['tidal', 'qobuz', 'deezer'];
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
@@ -2136,15 +2062,6 @@ class _ServiceSelector extends ConsumerWidget {
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) ...[
@@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
final hasError = extension.status == 'error';
return PopScope(
canPop: true, // Always allow back gesture
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
+1 -3
View File
@@ -61,7 +61,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true, // Always allow back gesture
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
@@ -600,14 +600,12 @@ class _SearchProviderSelector extends ConsumerWidget {
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
// Always allow tapping: built-in providers are always available
final hasAnyProvider =
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) {
// Check built-in first
if (_builtInProviders.containsKey(settings.searchProvider)) {
currentProviderName = _builtInProviders[settings.searchProvider]!;
} else {
@@ -23,21 +23,15 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
int _androidSdkVersion = 0;
bool _hasStoragePermission = false;
/// Convert SAF content URI to a readable display path
String _getDisplayPath(String path) {
if (!path.startsWith('content://')) return path;
// Extract the path portion from SAF tree URI
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
// -> /storage/emulated/0/Music
try {
final uri = Uri.parse(path);
final treePath =
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final treePath = uri.pathSegments.last;
final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
}
// For SD card or other volumes, just show the decoded path
return decoded;
} catch (_) {
return path;
+1 -1
View File
@@ -136,7 +136,7 @@ class _LogScreenState extends State<LogScreen> {
final logs = _filteredLogs;
return PopScope(
canPop: true, // Always allow back gesture
canPop: true,
child: Scaffold(
body: CustomScrollView(
controller: _scrollController,
@@ -19,7 +19,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true, // Always allow back gesture
canPop: true,
child: Scaffold(
body: CustomScrollView(
slivers: [
@@ -340,12 +340,6 @@ class _ProviderItem extends StatelessWidget {
icon: Icons.graphic_eq,
isBuiltIn: true,
);
case 'youtube':
return _ProviderInfo(
name: 'YouTube',
icon: Icons.play_circle_outline,
isBuiltIn: true,
);
default:
return _ProviderInfo(
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/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
class SettingsTab extends ConsumerWidget {
const SettingsTab({super.key});
@@ -150,26 +151,6 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus();
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),
),
);
Navigator.of(context).push(slidePageRoute(page: page));
}
}
+1 -7
View File
@@ -441,14 +441,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
void _nextPage() {
bool canProceed = false;
// Step 0 is Welcome, always can proceed
if (_currentStep == 0) {
canProceed = true;
} else {
// Logic for other steps (offset by 1 because of welcome step)
// Step 1: Storage
// Step 2: Notification (if android 13+) OR Directory
// etc.
canProceed = _isStepCompleted(_currentStep);
}
@@ -470,9 +465,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
bool _isStepCompleted(int step) {
if (step == 0) return true; // Welcome
if (step == 0) return true;
// Adjust step index for logic because we added Welcome at 0
final logicStep = step - 1;
if (_androidSdkVersion >= 33) {
+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/providers/store_provider.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/utils/app_bar_layout.dart';
@@ -58,7 +59,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
final downloadingId = ref.watch(
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 filteredExtensions = StoreState(
extensions: extensions,
@@ -139,7 +142,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
tooltip: 'Clear',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
@@ -151,23 +154,37 @@ class _StoreTabState extends ConsumerState<StoreTab> {
: null,
border: OutlineInputBorder(
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,
fillColor:
Theme.of(context).brightness == Brightness.dark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
horizontal: 20,
vertical: 16,
),
),
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(
label: context.l10n.storeFilterIntegration,
icon: Icons.link,
isSelected: selectedCategory == StoreCategory.integration,
isSelected:
selectedCategory == StoreCategory.integration,
onTap: () => ref
.read(storeProvider.notifier)
.setCategory(StoreCategory.integration),
@@ -242,8 +260,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
if (isLoading && extensions.isEmpty)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: TrackListSkeleton(itemCount: 6),
),
)
else if (error != null && extensions.isEmpty)
SliverFillRemaining(child: _buildErrorState(error, colorScheme))
@@ -309,9 +330,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
const SizedBox(height: 24),
Text(
context.l10n.storeAddRepoTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
@@ -322,16 +343,23 @@ class _StoreTabState extends ConsumerState<StoreTab> {
labelText: context.l10n.storeRepoUrlLabel,
prefixIcon: const Icon(Icons.link),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.outlineVariant),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(28),
borderSide: BorderSide(color: colorScheme.primary, width: 2),
),
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
keyboardType: TextInputType.url,
autocorrect: false,
@@ -347,7 +375,11 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
child: Row(
children: [
Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer),
Icon(
Icons.error_outline,
size: 20,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
@@ -416,7 +448,31 @@ class _StoreTabState extends ConsumerState<StoreTab> {
labelText: context.l10n.storeNewRepoUrlLabel,
prefixIcon: const Icon(Icons.link),
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,
@@ -503,7 +559,9 @@ class _StoreTabState extends ConsumerState<StoreTab> {
),
const SizedBox(height: 16),
Text(
hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions,
hasFilters
? context.l10n.storeEmptyNoResults
: context.l10n.storeEmptyNoExtensions,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
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/mime_utils.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/audio_analysis_widget.dart';
final _log = AppLogger('TrackMetadata');
@@ -59,19 +60,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _fileExists = false;
bool _hasCheckedFile = false;
int? _fileSize;
String? _lyrics; // Cleaned lyrics for display (no timestamps)
String? _rawLyrics; // Raw LRC with timestamps for embedding
String? _lyrics;
String? _rawLyrics;
bool _lyricsLoading = false;
String? _lyricsError;
String? _lyricsSource;
bool _showTitleInAppBar = false;
bool _lyricsEmbedded = false;
bool _isEmbedding = false; // Track embed operation in progress
bool _isEmbedding = false;
bool _isInstrumental = false;
bool _isConverting = false; // Track convert operation in progress
bool _isConverting = false;
bool _hasMetadataChanges = false;
bool _hasLoadedResolvedAudioMetadata = false;
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
Map<String, dynamic>? _editedMetadata;
String? _embeddedCoverPreviewPath;
final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern = RegExp(
@@ -307,12 +308,10 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
storedQuality: _quality,
);
// Fill in album name from file tags if stored value is empty
final needsAlbum =
resolvedAlbum != null &&
resolvedAlbum.isNotEmpty &&
(albumName.isEmpty);
// Fill in duration from file if stored value is missing/zero
final needsDuration =
resolvedDuration != null &&
resolvedDuration > 0 &&
@@ -519,6 +518,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String get _coverHeroTag =>
_isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId';
String? get _coverUrl =>
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
String? get _localCoverPath =>
@@ -527,7 +528,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _service => _isLocalItem ? 'local' : _downloadItem!.service;
DateTime get _addedAt {
if (_isLocalItem) {
// Use file modification time if available, otherwise fall back to scannedAt
final modTime = _localLibraryItem!.fileModTime;
if (modTime != null && modTime > 0) {
return DateTime.fromMillisecondsSinceEpoch(modTime);
@@ -577,7 +577,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get cleanFilePath {
var path = _filePath;
if (path.startsWith('EXISTS:')) path = path.substring(7);
// Strip CUE virtual path suffix for filesystem operations
if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path);
return path;
}
@@ -770,6 +769,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_buildLyricsCard(context, colorScheme),
if (_fileExists) ...[
const SizedBox(height: 16),
AudioAnalysisCard(filePath: _filePath),
],
const SizedBox(height: 24),
_buildActionButtons(context, ref, colorScheme, _fileExists),
@@ -790,38 +794,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
double expandedHeight,
bool showContent,
) {
return Stack(
fit: StackFit.expand,
children: [
if (_hasPath(_embeddedCoverPreviewPath))
Image.file(
final coverChild = _hasPath(_embeddedCoverPreviewPath)
? Image.file(
File(_embeddedCoverPreviewPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else if (_coverUrl != null)
CachedNetworkImage(
: _coverUrl != null
? CachedNetworkImage(
imageUrl: _coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
)
else if (_localCoverPath != null && _localCoverPath!.isNotEmpty)
Image.file(
: _localCoverPath != null && _localCoverPath!.isNotEmpty
? Image.file(
File(_localCoverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
)
else
Container(
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 80,
color: colorScheme.onSurfaceVariant,
),
),
);
return Stack(
fit: StackFit.expand,
children: [
Hero(
tag: _coverHeroTag,
child: Material(color: Colors.transparent, child: coverChild),
),
Positioned(
left: 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) ...[
const SizedBox(height: 16),
Center(
@@ -1662,7 +1669,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
try {
final durationMs = (duration ?? 0) * 1000;
// First, check if lyrics are embedded in the file
if (_fileExists) {
final embeddedResult =
await PlatformBridge.getLyricsLRCWithSource(
@@ -1696,12 +1702,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
// No embedded lyrics, fetch from online
final result = await PlatformBridge.getLyricsLRCWithSource(
_spotifyId ?? '',
trackName,
artistName,
filePath: null, // Don't check file again
filePath: null,
durationMs: durationMs,
).timeout(const Duration(seconds: 20));
@@ -1727,9 +1732,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final cleanLyrics = _cleanLrcForDisplay(lrcText);
setState(() {
_lyrics = cleanLyrics;
_rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding
_rawLyrics = lrcText;
_lyricsSource = source.isNotEmpty ? source : null;
_lyricsEmbedded = false; // Lyrics from online, not embedded
_lyricsEmbedded = false;
_lyricsLoading = false;
});
}
@@ -1756,7 +1761,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
setState(() => _isEmbedding = true);
// Capture l10n strings before async gaps to avoid use_build_context_synchronously
final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage;
final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics;
final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat;
@@ -1986,7 +1990,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
@@ -2033,7 +2036,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
@@ -2126,7 +2128,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
@@ -2182,7 +2183,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
@@ -2257,7 +2257,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final result = await PlatformBridge.reEnrichFile(request);
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>?;
if (enriched != null && mounted) {
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) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
@@ -2357,7 +2355,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
);
// Cleanup temp files
if (_hasPath(downloadedCoverPath)) {
try {
await File(downloadedCoverPath!).delete();
@@ -2375,7 +2372,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
}
// Cleanup temp files
if (tempPath != null && tempPath.isNotEmpty) {
try {
await File(tempPath).delete();
@@ -2397,7 +2393,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
// Cleanup temp cover from Go backend
if (_hasPath(downloadedCoverPath)) {
try {
await File(downloadedCoverPath!).delete();
@@ -2462,7 +2457,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
for (final line in lines) {
var cleaned = line.trim();
// Skip metadata tags
if (_lrcMetadataPattern.hasMatch(cleaned) &&
!_lrcBackgroundLinePattern.hasMatch(cleaned)) {
continue;
@@ -2474,7 +2468,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
cleaned = bgMatch.group(1)?.trim() ?? '';
}
// Remove line timestamp, inline word-by-word timestamps, and speaker prefix.
cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim();
cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, '');
cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, '');
@@ -2685,11 +2678,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
/// Whether the current file is a CUE sheet (or CUE-referenced)
bool get _isCueFile {
// Check if the raw path has a CUE virtual path suffix
if (isCueVirtualPath(rawFilePath)) return true;
final lower = cleanFilePath.toLowerCase();
if (lower.endsWith('.cue')) return true;
// Check if local library item has cue+ format
if (_isLocalItem && _localLibraryItem != null) {
final format = _localLibraryItem!.format ?? '';
if (format.startsWith('cue+')) return true;
@@ -2815,7 +2806,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
final currentFormat = _currentFileFormat;
final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A';
// Build available target formats based on source
final formats = <String>[];
if (currentFormat == 'FLAC') {
formats.addAll(['ALAC', 'MP3', 'Opus']);
@@ -2906,7 +2896,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}).toList(),
),
// Only show bitrate for lossy targets
if (!isLosslessTarget) ...[
const SizedBox(height: 16),
Text(
@@ -2933,7 +2922,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
],
// Show lossless indicator
if (isLosslessTarget && isLosslessSource) ...[
const SizedBox(height: 16),
Row(
@@ -2991,14 +2979,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
void _showCueSplitSheet(BuildContext context) async {
// Strip the #trackNN suffix from virtual CUE paths to get the real .cue path
var cuePath = cleanFilePath;
final trackSuffix = RegExp(r'#track\d+$');
if (trackSuffix.hasMatch(cuePath)) {
cuePath = cuePath.replaceFirst(trackSuffix, '');
}
// Show loading indicator
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)),
);
@@ -3093,7 +3079,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
),
const SizedBox(height: 16),
// Track list preview (scrollable, max 200px)
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
@@ -3315,7 +3300,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
workingAudioPath = tempPath;
}
// Determine output directory
final String outputDir;
final treeUri = !_isLocalItem
? (_downloadItem?.downloadTreeUri ?? '')
@@ -3342,7 +3326,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!mounted) return;
_showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length));
// Extract cover from audio file for embedding
String? coverPath;
try {
final tempDir = await getTemporaryDirectory();
@@ -3385,11 +3368,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
for (final path in finalOutputPaths) {
if (path.toLowerCase().endsWith('.flac')) {
try {
// Read existing metadata first
final metadata = await PlatformBridge.readFileMetadata(path);
if (metadata['error'] == null) {
final fields = <String, String>{'cover_path': coverPath};
// Preserve existing fields
for (final entry in metadata.entries) {
if (entry.key == 'error' || entry.value == null) continue;
final v = entry.value.toString().trim();
@@ -3415,7 +3396,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
finalOutputPaths = exportedUris;
}
// Cleanup cover temp
if (coverPath != null) {
try {
await File(coverPath).delete();
@@ -3437,7 +3417,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_showSnackBarMessage(_l10nCueSplitFailed);
}
} finally {
// Cleanup SAF temp audio copy
if (safTempAudioPath != null) {
try {
await File(safTempAudioPath).delete();
@@ -3556,7 +3535,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? safTempPath;
if (isSaf) {
// Copy SAF file to temp for processing
safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath);
if (safTempPath == null) {
if (mounted) {
@@ -3576,10 +3554,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it
deleteOriginal: !isSaf,
);
// Cleanup cover temp
if (coverPath != null) {
try {
await File(coverPath).delete();
@@ -3587,7 +3564,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
if (newPath == null) {
// Cleanup SAF temp if needed
if (safTempPath != null) {
try {
await File(safTempPath).delete();
@@ -3649,7 +3625,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
newExt = '.flac';
mimeType = 'audio/flac';
break;
default: // mp3
default:
newExt = '.mp3';
mimeType = 'audio/mpeg';
break;
@@ -3689,7 +3665,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_log.w('Converted SAF file created but failed deleting original URI');
}
// Update history with new SAF info
if (!_isLocalItem) {
await HistoryDatabase.instance.updateFilePath(
_downloadItem!.id,
@@ -3701,7 +3676,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
await ref.read(downloadHistoryProvider.notifier).reloadFromStorage();
}
// Cleanup temp files
try {
await File(newPath).delete();
} catch (_) {}
@@ -3711,7 +3685,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} catch (_) {}
}
} else {
// Regular file: update history with new path
if (!_isLocalItem) {
await HistoryDatabase.instance.updateFilePath(
_downloadItem!.id,
@@ -3730,7 +3703,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
content: Text(context.l10n.trackConvertSuccess(targetFormat)),
),
);
// Pop and let the caller refresh
Navigator.pop(context, true);
}
} catch (e) {
@@ -3748,7 +3720,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
WidgetRef ref,
ColorScheme colorScheme,
) async {
// Read current metadata from file, fall back to item data on failure
Map<String, dynamic>? fileMetadata;
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
@@ -3759,7 +3730,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
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) {
final v = fileMetadata?[key]?.toString();
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
@@ -3805,7 +3775,6 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)),
);
// Re-read metadata from file to refresh the display
try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
setState(() => _editedMetadata = refreshed);
@@ -4050,10 +4019,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
String? _currentCoverTempDir;
bool _loadingCurrentCover = false;
// Auto-fill field selection which fields the user wants to fetch
final Set<String> _autoFillFields = {};
// All auto-fillable fields and their mapping
static const _fieldDefs = <String, String>{
'title': 'title',
'artist': 'artist',
@@ -4679,7 +4646,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
throw StateError('No metadata match resolved for auto-fill');
}
// Extract basic metadata from search result
final enriched = <String, String>{
'title': (selectedBest['name'] ?? '').toString(),
'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '')
@@ -4757,7 +4723,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return;
// Fetch genre/label/copyright from Deezer extended metadata
if (needsExtended && deezerId != null) {
try {
final extended = await PlatformBridge.getDeezerExtendedMetadata(
@@ -4775,10 +4740,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (!mounted) return;
// Apply selected fields to controllers
var filledCount = 0;
for (final key in _autoFillFields) {
if (key == 'cover') continue; // Handle cover separately below
if (key == 'cover') continue;
final value = enriched[key];
if (value != null &&
value.isNotEmpty &&
@@ -4792,7 +4756,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
}
}
// Handle cover art download
if (_autoFillFields.contains('cover')) {
final coverUrl =
(selectedBest['cover_url'] ?? selectedBest['images'] ?? '')
@@ -5071,7 +5034,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
return;
}
// For SAF files, copy the processed temp file back
if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
@@ -5184,7 +5146,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
_field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl),
// Advanced fields toggle
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell(
@@ -5282,7 +5243,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 8),
// Quick select buttons
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
@@ -5302,7 +5262,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 8),
// Field chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
@@ -5339,7 +5298,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
),
),
const SizedBox(height: 10),
// Fetch button
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12),
child: SizedBox(
+2 -2
View File
@@ -198,8 +198,8 @@ class CsvImportService {
artistName: artistName ?? 'Unknown Artist',
albumName: albumName ?? 'Unknown Album',
isrc: isrc,
duration: 0, // Will be updated by enrichment later
coverUrl: null, // Will be fetched by enrichment
duration: 0,
coverUrl: null,
),
);
}
-7
View File
@@ -1437,7 +1437,6 @@ class FFmpegService {
final cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$inputPath" ');
// Cover art as second input for M4A attached picture
final hasCover =
coverPath != null &&
coverPath.trim().isNotEmpty &&
@@ -1455,7 +1454,6 @@ class FFmpegService {
cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 ');
// Embed M4A metadata tags
final m4aTags = _convertToM4aTags(metadata);
for (final entry in m4aTags.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
@@ -1764,7 +1762,6 @@ class FFmpegService {
final outputPaths = <String>[];
final inputExt = audioPath.toLowerCase().split('.').last;
// For lossless formats, keep as FLAC; for others, keep original format
final outputExt =
(inputExt == 'flac' ||
inputExt == 'wav' ||
@@ -1836,14 +1833,10 @@ class FFmpegService {
final result = await _execute(command);
if (!result.success) {
_log.e('CUE split failed for track ${track.number}: ${result.output}');
// Continue with remaining tracks instead of failing completely
continue;
}
// Embed cover art if available (for FLAC output)
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
// Use the Go backend for FLAC cover embedding via PlatformBridge
// (handled by the caller)
}
outputPaths.add(outputPath);
+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)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
@@ -532,6 +546,29 @@ class HistoryDatabase {
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
Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0;
+32 -11
View File
@@ -255,20 +255,41 @@ class LibraryDatabase {
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(
'library',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
await db.transaction((txn) async {
final batch = txn.batch();
for (final json in items) {
batch.insert(
'library',
_jsonToDbRow(json),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
});
_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 {
final db = await database;
final rows = await db.query(
+23 -39
View File
@@ -83,24 +83,18 @@ class PlatformBridge {
static Future<Map<String, dynamic>> getDownloadProgress() async {
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 {
final result = await _channel.invokeMethod('getAllDownloadProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
return _decodeMapResult(result);
}
static Stream<Map<String, dynamic>> downloadProgressStream() {
return _downloadProgressEvents.receiveBroadcastStream().map((event) {
if (event is String) {
return jsonDecode(event) as Map<String, dynamic>;
}
if (event is Map) {
return Map<String, dynamic>.from(event);
}
return const <String, dynamic>{};
});
return _downloadProgressEvents.receiveBroadcastStream().map(
_decodeMapResult,
);
}
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 {
_log.i('setLibraryCoverCacheDir: $cacheDir');
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(
String folderPath,
) async {
@@ -1108,10 +1099,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Perform an incremental scan of the library folder
/// Only scans files that are new or have changed since last scan
/// [existingFiles] is a map of filePath -> modTime (unix millis)
/// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
String folderPath,
Map<String, int> existingFiles,
@@ -1146,8 +1133,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
/// Incremental SAF tree scan - only scans new or modified files
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
static Future<Map<String, dynamic>> scanSafTreeIncremental(
String treeUri,
Map<String, int> existingFiles,
@@ -1173,8 +1158,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get last-modified timestamps for a list of SAF file URIs.
/// Returns map uri -> modTime (unix millis), only for files that still exist.
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
final result = await _channel.invokeMethod('getSafFileModTimes', {
'uris': jsonEncode(uris),
@@ -1183,29 +1166,35 @@ class PlatformBridge {
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
}
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress');
return jsonDecode(result as String) as Map<String, dynamic>;
return _decodeMapResult(result);
}
static Stream<Map<String, dynamic>> libraryScanProgressStream() {
return _libraryScanProgressEvents.receiveBroadcastStream().map((event) {
if (event is String) {
return jsonDecode(event) as Map<String, dynamic>;
}
if (event is Map) {
return Map<String, dynamic>.from(event);
}
return const <String, dynamic>{};
});
return _libraryScanProgressEvents.receiveBroadcastStream().map(
_decodeMapResult,
);
}
/// Cancel ongoing library scan
static Future<void> cancelLibraryScan() async {
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
/// 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(
String filePath,
) async {
@@ -1367,10 +1355,6 @@ class PlatformBridge {
await _channel.invokeMethod('clearStoreCache');
}
/// Parse a .cue file and return split information (track listing, timing, metadata).
/// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[]
/// Each track has: number, title, artist, isrc, composer, start_sec, end_sec
/// [audioDir] optionally overrides the directory for audio file resolution (used for SAF).
static Future<Map<String, dynamic>> parseCueSheet(
String cuePath, {
String audioDir = '',
-3
View File
@@ -80,7 +80,6 @@ class ShareIntentService {
bool isInitial = false,
}) {
for (final file in files) {
// Check both path and message - apps may share URL in either field
final textsToCheck = [file.path, if (file.message != null) file.message!];
for (final textToCheck in textsToCheck) {
@@ -100,13 +99,11 @@ class ShareIntentService {
String? _extractMusicUrl(String text) {
if (text.isEmpty) return null;
// Try Spotify URI first
final uriMatch = _spotifyUriPattern.firstMatch(text);
if (uriMatch != null) {
return uriMatch.group(0);
}
// Try all URL patterns
final patterns = [
_spotifyUrlPattern,
_deezerUrlPattern,
+4 -10
View File
@@ -89,9 +89,7 @@ class AppTheme {
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
color: scheme.surfaceContainerLow,
surfaceTintColor: scheme.surfaceTint,
);
@@ -148,9 +146,7 @@ class AppTheme {
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
InputDecorationTheme(
filled: true,
fillColor: scheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.3),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
@@ -175,9 +171,7 @@ class AppTheme {
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
ListTileThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
);
@@ -237,7 +231,7 @@ class AppTheme {
);
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
backgroundColor: scheme.surfaceContainerLow,
selectedColor: scheme.secondaryContainer,
);
+10 -10
View File
@@ -27,7 +27,6 @@ Future<void> navigateToArtist(
final normalizedArtistId = _normalizeArtistId(artistId);
// If we have a valid artist ID already, navigate directly
if (normalizedArtistId != null &&
_canNavigateArtistDirectly(
artistId: normalizedArtistId,
@@ -43,7 +42,6 @@ Future<void> navigateToArtist(
return;
}
// Search Deezer to resolve the artist ID
_showLoadingSnackBar(context, 'Looking up artist...');
try {
final results = await PlatformBridge.searchDeezerAll(
@@ -60,7 +58,6 @@ Future<void> navigateToArtist(
return;
}
// Find best match - prefer exact name match (case-insensitive)
Map<String, dynamic>? bestMatch;
final lowerName = artistName.toLowerCase().trim();
for (final a in artistList) {
@@ -113,7 +110,6 @@ Future<void> navigateToAlbum(
}) async {
if (albumName.isEmpty) return;
// If we have a valid album ID already, navigate directly
if (albumId != null &&
albumId.isNotEmpty &&
albumId != 'unknown' &&
@@ -128,16 +124,13 @@ Future<void> navigateToAlbum(
return;
}
// If it's extension-based content without an ID, can't search Deezer for it
if (extensionId != null) {
_showUnavailable(context, 'Album');
return;
}
// Search Deezer to resolve the album ID
_showLoadingSnackBar(context, 'Looking up album...');
try {
// Build search query: "albumName artistName" for better accuracy
final query = artistName != null && artistName.isNotEmpty
? '$albumName $artistName'
: albumName;
@@ -156,7 +149,6 @@ Future<void> navigateToAlbum(
return;
}
// Find best match - prefer exact name match (case-insensitive)
Map<String, dynamic>? bestMatch;
final lowerName = albumName.toLowerCase().trim();
for (final a in albumList) {
@@ -225,11 +217,19 @@ void _pushAlbumScreen(
String? coverUrl,
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(
context,
(context) => extensionId != null
(context) => isExtension && resolvedExtensionId != null
? ExtensionAlbumScreen(
extensionId: extensionId,
extensionId: resolvedExtensionId,
albumId: albumId,
albumName: albumName,
coverUrl: coverUrl,
+14 -1
View File
@@ -22,6 +22,9 @@ const _audioExtensions = <String>[
'.aac',
];
const _maxPathMatchKeyCacheSize = 6000;
final Map<String, Set<String>> _pathMatchKeyCache = <String, Set<String>>{};
/// Strips a trailing audio extension from [path] if present.
/// Returns the path without extension, or `null` if no known audio extension
/// was found.
@@ -41,6 +44,11 @@ Set<String> buildPathMatchKeys(String? filePath) {
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
if (cleaned.isEmpty) return const {};
final cached = _pathMatchKeyCache.remove(cleaned);
if (cached != null) {
_pathMatchKeyCache[cleaned] = cached;
return cached;
}
final keys = <String>{};
final visited = <String>{};
@@ -118,7 +126,12 @@ Set<String> buildPathMatchKeys(String? filePath) {
}
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) {
+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