Compare commits

...

15 Commits

Author SHA1 Message Date
zarzet f9dd82010f fix: skip M4A conversion for existing files and prevent empty SAF folders on duplicates 2026-02-08 15:44:05 +07:00
zarzet f0790b627d perf: optimize album, artist, and playlist screens
- Scope settingsProvider watches with select() for localLibrary flags

- Wrap popular track items in Consumer for scoped provider watches

- Apply dart format reformatting
2026-02-08 15:00:57 +07:00
zarzet 55350fffa0 perf: optimize home tab and queue tab widget rebuilds
- Use ValueNotifier+ValueListenableBuilder for file existence checks instead of setState

- Scope Riverpod watches with field-level select() to reduce unnecessary rebuilds

- Pass precomputed params to _TrackItemWithStatus to avoid per-item provider watches

- Memoize filter/sort computations per build pass

- Isolate queue header/list into dedicated Consumer slivers

- Fix Positioned/ValueListenableBuilder nesting order in grid view
2026-02-08 14:20:18 +07:00
zarzet 7229602343 feat: replace date filter with sorting (latest/oldest/A-Z/Z-A)
- Remove broken date range filter (today/week/month/year)
- Add sort options: Latest, Oldest, A-Z, Z-A
- Sorting applies to tracks (all/singles tabs) and albums tab
- Add l10n keys for sort labels
2026-02-08 13:44:02 +07:00
zarzet 1c81c53699 fix: library filters now apply to date/albums and update tab counts
- Remove redundant manual export button from queue header
- Add date range filtering support for local library items
- Apply advanced filters (date, quality, format, source) to album tab
- Tab chip counts (All/Albums/Singles) now reflect filtered results
- Extract reusable filter helpers: _passesDateFilter, _passesQualityFilter, _passesFormatFilter
- Add _filterGroupedAlbums and _filterGroupedLocalAlbums methods
2026-02-08 13:09:19 +07:00
zarzet 5256d6197b fix: metadata enrichment bug and upgrade go-flac to v2
- Fix metadata enrichment bug where failed downloads poison connection pool
  - Create separate metadataTransport for Deezer API calls
  - Add immediate connection cleanup after download failures
- Fix Samsung One UI local library scan with MediaStore fallback
- Fix 'In Library' tracks still showing as downloadable
- Upgrade go-flac packages to v2 (flacpicture v2.0.2, flacvorbis v2.0.2, go-flac v2.0.4)
- Update CHANGELOG.md v3.5.2
2026-02-08 12:01:08 +07:00
Zarz Eleutherius 79a6c8cdc0 Merge pull request #139 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-08 08:31:29 +07:00
renovate[bot] aa3b4d7d1e fix(deps): update go dependencies to v2 2026-02-07 21:39:25 +00:00
zarzet cd220a4650 merge: sync main into dev (README updates) 2026-02-08 02:51:05 +07:00
Zarz Eleutherius d71b2a9ab8 Update README to remove Search Source and enhance Telegram links 2026-02-08 02:48:29 +07:00
zarzet a2efe7243d docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:36:15 +07:00
zarzet e0acda14e4 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:33:56 +07:00
Zarz Eleutherius 029ab8ea47 Update VirusTotal badge link in README 2026-02-08 02:30:22 +07:00
zarzet 38f9498006 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:26:27 +07:00
Zarz Eleutherius af203ae51f Update VirusTotal badge link in README 2026-02-07 14:44:19 +07:00
35 changed files with 6901 additions and 4866 deletions
+36
View File
@@ -1,5 +1,41 @@
# Changelog # Changelog
## [3.5.2] - 2026-02-08
### Performance
- Home tab search result sections are now virtualized with `SliverList` (lazy item build) instead of eager `Column` rendering, reducing frame drops on large result sets
- Home tab now narrows Riverpod subscriptions using field-level `select(...)` for search/provider state to reduce unnecessary full-tab rebuilds
- Search provider dropdown now watches only required fields (`searchProvider`, `metadataSource`, `extensions`) instead of full provider states
- Track row rendering in Home search now receives precomputed thumbnail sizing/local-library flags from parent to avoid repeated per-item provider watches
- Removed thumbnail `debugPrint` calls inside track row `build()` to reduce runtime overhead during scrolling/rebuilds
- Queue tab root subscription no longer watches full queue item list; it now watches only queue presence (`items.isNotEmpty`) to avoid full Library UI rebuilds on every progress tick
- Queue download header/list rendering has been isolated into dedicated `Consumer` slivers; header now watches only queue length (`items.length`) while item list watches queue item updates
- Queue filter/sort computations are now centralized and memoized per filter mode within a build pass (`all`/`albums`/`singles`), reducing repeated list transforms for chip counts and page content
- Selection bottom bar content is now computed only when selection mode is active, removing hidden-state heavy list preparation
- File existence checks in queue/library rows now use per-path `ValueNotifier` + `ValueListenableBuilder` updates instead of triggering global `setState`, reducing unnecessary whole-tab repaints
### Changed
- Replaced date range filter with sorting options in Library tab: Latest, Oldest, A-Z, Z-A
- Sorting applies to all views: unified items, downloaded albums, and local library albums
- Local library items now use file modification time (`fileModTime`) for sorting instead of scan time, providing more accurate chronological ordering
- Removed redundant manual "Export Failed Downloads" button from Library UI (auto-export setting in Settings is sufficient)
- Library filters (quality, format, source) now correctly apply to album tabs and update tab chip counts (All/Albums/Singles)
### Fixed
- Fixed local library scan crashing on Samsung One UI devices due to MediaStore URI mismatch in SAF tree traversal
- Added MediaStore URI fallback in SAF file reader: when SAF permission is denied for Samsung-returned MediaStore URIs, automatically retries using READ_MEDIA_AUDIO permission
- Hardened SAF scan with per-directory and per-file error handling: scan now skips problematic files instead of aborting entirely
- Added visited directory tracking to prevent infinite loops from circular SAF references
- Fixed metadata enrichment cascading failure after one queued download fails: metadata APIs (Deezer, SongLink, Spotify) now use isolated `metadataTransport` so failed download connections cannot poison metadata requests
- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads
- Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`)
- Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search)
- Fixed FFmpeg M4A-to-FLAC conversion erroneously triggered on already-existing FLAC files when re-downloading duplicates via Tidal
- Fixed SAF download creating empty artist/album folders when re-downloading duplicate tracks; directory is now only created after confirming the file does not already exist
## [3.5.1] - 2026-02-08 ## [3.5.1] - 2026-02-08
### Performance ### Performance
+12 -19
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/f6ba8fa4a572d69f6196f980733089cb741088e3ceb49d0bd3ceda5a694a2466/) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center"> <div align="center">
@@ -24,15 +24,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
<img src="assets/images/4.jpg?v=2" width="200" /> <img src="assets/images/4.jpg?v=2" width="200" />
</p> </p>
## Search Source
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Extensions** | Install additional search providers from the Store |
## Extensions ## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently. Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
@@ -54,15 +45,8 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
## Telegram ## Telegram
<p align="center"> [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
<a href="https://t.me/spotiflac"> [![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflac_chat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## FAQ ## FAQ
@@ -108,6 +92,15 @@ You are solely responsible for:
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
## API Credits
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
- **Lyrics**: [LRCLib](https://lrclib.net)
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
> [!TIP] > [!TIP]
> >
> **Star Us**, You will receive all release notifications from GitHub without any delay ~ > **Star Us**, You will receive all release notifications from GitHub without any delay ~
@@ -424,36 +424,159 @@ class MainActivity: FlutterFragmentActivity() {
return obj.toString() return obj.toString()
} }
private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { /**
val mime = contentResolver.getType(uri) * Detect whether a content URI belongs to the MediaStore provider.
val nameHint = ( * Samsung One UI may return MediaStore URIs from SAF tree traversal,
DocumentFile.fromSingleUri(this, uri)?.name * which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission
?: uri.lastPathSegment * instead of SAF tree permission.
?: "" */
).lowercase(Locale.ROOT) private fun isMediaStoreUri(uri: Uri): Boolean {
val extFromName = when { val authority = uri.authority ?: return false
nameHint.endsWith(".m4a") -> ".m4a" return authority == "media" ||
nameHint.endsWith(".mp3") -> ".mp3" authority.startsWith("media.") ||
nameHint.endsWith(".opus") -> ".opus" authority.contains("media")
nameHint.endsWith(".flac") -> ".flac" }
/**
* 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()) {
val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: ""
val ext = extFromFileName(name)
if (ext.isNotBlank()) return ext
}
}
} catch (_: Exception) {}
// Try MIME_TYPE
try {
val mime = contentResolver.getType(uri)
val ext = extFromMimeType(mime)
if (ext.isNotBlank()) return ext
} catch (_: Exception) {}
return fallbackExt ?: ""
}
private fun extFromFileName(name: String): String {
return when {
name.endsWith(".m4a") -> ".m4a"
name.endsWith(".mp3") -> ".mp3"
name.endsWith(".opus") -> ".opus"
name.endsWith(".flac") -> ".flac"
name.endsWith(".ogg") -> ".ogg"
else -> "" else -> ""
} }
val extFromMime = when (mime) { }
private fun extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a" "audio/mp4" -> ".m4a"
"audio/mpeg" -> ".mp3" "audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus" "audio/ogg" -> ".opus"
"audio/flac" -> ".flac" "audio/flac" -> ".flac"
else -> "" else -> ""
} }
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "") }
val suffix: String? = if (ext.isNotBlank()) ext else null
val tempFile = File.createTempFile("saf_", suffix, cacheDir) private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
contentResolver.openInputStream(uri)?.use { input -> var tempFile: File? = null
FileOutputStream(tempFile).use { output -> var success = false
input.copyTo(output)
try {
val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null }
val nameHint = (
try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
?: uri.lastPathSegment
?: ""
).lowercase(Locale.ROOT)
val extFromName = extFromFileName(nameHint)
val extFromMime = extFromMimeType(mime)
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
val suffix: String? = if (ext.isNotBlank()) ext else null
tempFile = File.createTempFile("saf_", suffix, cacheDir)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
} ?: return null
success = true
return tempFile.absolutePath
} catch (e: SecurityException) {
// SAF permission denied - try MediaStore fallback for Samsung One UI
// which may return MediaStore URIs from SAF tree traversal
if (isMediaStoreUri(uri)) {
android.util.Log.d(
"SpotiFLAC",
"SAF denied for MediaStore URI, trying MediaStore fallback: $uri",
)
val result = copyMediaStoreUriToTemp(uri, fallbackExt)
if (result != null) {
success = true
return result
}
} }
} ?: return null android.util.Log.w(
return tempFile.absolutePath "SpotiFLAC",
"SAF read denied for $uri: ${e.message}",
)
return null
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Failed copying SAF uri $uri to temp: ${e.message}",
)
return null
} finally {
if (!success) {
try {
tempFile?.delete()
} catch (_: Exception) {}
}
}
}
/**
* Fallback for Samsung One UI: read a MediaStore content URI using
* READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF.
* This handles the case where SAF tree traversal returns MediaStore URIs
* that the SAF document provider cannot access.
*/
private fun copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? {
var tempFile: File? = null
try {
val ext = resolveMediaStoreExt(uri, fallbackExt)
val suffix: String? = if (ext.isNotBlank()) ext else null
tempFile = File.createTempFile("ms_", suffix, cacheDir)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
} ?: run {
tempFile.delete()
return null
}
android.util.Log.d(
"SpotiFLAC",
"MediaStore fallback succeeded for $uri",
)
return tempFile.absolutePath
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"MediaStore fallback also failed for $uri: ${e.message}",
)
try { tempFile?.delete() } catch (_: Exception) {}
return null
}
} }
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean { private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
@@ -479,22 +602,30 @@ class MainActivity: FlutterFragmentActivity() {
val relativeDir = req.optString("saf_relative_dir", "") val relativeDir = req.optString("saf_relative_dir", "")
val outputExt = normalizeExt(req.optString("saf_output_ext", "")) val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt) 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)
if (existing != null && existing.isFile && existing.length() > 0) {
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
obj.put("file_path", existing.uri.toString())
obj.put("file_name", existing.name ?: fileName)
obj.put("already_exists", true)
return obj.toString()
}
}
// Only create the directory now that we know we need to download
val targetDir = ensureDocumentDir(treeUri, relativeDir) val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory") ?: return errorJson("Failed to access SAF directory")
val fileName = buildSafFileName(req, outputExt) val existingFile = targetDir.findFile(fileName)
val existing = targetDir.findFile(fileName) val document = existingFile ?: targetDir.createFile(mimeType, fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
obj.put("file_path", existing.uri.toString())
obj.put("file_name", existing.name ?: fileName)
obj.put("already_exists", true)
return obj.toString()
}
val document = existing ?: targetDir.createFile(mimeType, fileName)
?: return errorJson("Failed to create SAF file") ?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw") val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -547,9 +678,14 @@ class MainActivity: FlutterFragmentActivity() {
resetSafScanProgress() resetSafScanProgress()
safScanCancel = false safScanCancel = false
safScanActive = true safScanActive = true
updateSafScanProgress {
it.currentFile = "Scanning folders..."
}
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>() val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val visitedDirUris = mutableSetOf<String>()
var traversalErrors = 0
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque() val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "") queue.add(root to "")
@@ -561,22 +697,52 @@ class MainActivity: FlutterFragmentActivity() {
} }
val (dir, path) = queue.removeFirst() val (dir, path) = queue.removeFirst()
for (child in dir.listFiles()) { val dirUri = dir.uri.toString()
if (!visitedDirUris.add(dirUri)) {
continue
}
val children = try {
dir.listFiles()
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF scan: failed listing directory $dirUri: ${e.message}",
)
continue
}
for (child in children) {
if (safScanCancel) { if (safScanCancel) {
updateSafScanProgress { it.isComplete = true } updateSafScanProgress { it.isComplete = true }
return "[]" return "[]"
} }
if (child.isDirectory) { try {
val childName = child.name ?: continue if (child.isDirectory) {
val childPath = if (path.isBlank()) childName else "$path/$childName" val childName = child.name ?: continue
queue.add(child to childPath) val childPath = if (path.isBlank()) childName else "$path/$childName"
} else if (child.isFile) { val childUri = child.uri.toString()
val name = child.name ?: continue if (childUri == dirUri || visitedDirUris.contains(childUri)) {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) continue
if (ext.isNotBlank() && supportedExt.contains(".$ext")) { }
audioFiles.add(child to path) queue.add(child to childPath)
} else if (child.isFile) {
val name = child.name ?: continue
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
audioFiles.add(child to path)
}
} }
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF scan: skipped child under $dirUri: ${e.message}",
)
} }
} }
} }
@@ -595,7 +761,7 @@ class MainActivity: FlutterFragmentActivity() {
val results = JSONArray() val results = JSONArray()
var scanned = 0 var scanned = 0
var errors = 0 var errors = traversalErrors
for ((doc, _) in audioFiles) { for ((doc, _) in audioFiles) {
if (safScanCancel) { if (safScanCancel) {
@@ -603,14 +769,22 @@ class MainActivity: FlutterFragmentActivity() {
return "[]" return "[]"
} }
val name = doc.name ?: "" val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress { updateSafScanProgress {
it.currentFile = name it.currentFile = name
} }
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val tempPath = copyUriToTemp(doc.uri, fallbackExt) val tempPath = try {
copyUriToTemp(doc.uri, fallbackExt)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF scan: failed to copy ${doc.uri}: ${e.message}",
)
null
}
if (tempPath == null) { if (tempPath == null) {
errors++ errors++
} else { } else {
@@ -618,7 +792,7 @@ class MainActivity: FlutterFragmentActivity() {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) { if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson) val obj = JSONObject(metadataJson)
val lastModified = doc.lastModified() val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
obj.put("filePath", doc.uri.toString()) obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified) obj.put("fileModTime", lastModified)
results.put(obj) results.put(obj)
@@ -691,10 +865,15 @@ class MainActivity: FlutterFragmentActivity() {
resetSafScanProgress() resetSafScanProgress()
safScanCancel = false safScanCancel = false
safScanActive = true safScanActive = true
updateSafScanProgress {
it.currentFile = "Scanning folders..."
}
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
val currentUris = mutableSetOf<String>() val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
var traversalErrors = 0
// Collect all audio files with lastModified // Collect all audio files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque() val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
@@ -713,7 +892,24 @@ class MainActivity: FlutterFragmentActivity() {
} }
val (dir, path) = queue.removeFirst() val (dir, path) = queue.removeFirst()
for (child in dir.listFiles()) { val dirUri = dir.uri.toString()
if (!visitedDirUris.add(dirUri)) {
continue
}
val children = try {
dir.listFiles()
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: failed listing directory $dirUri: ${e.message}",
)
continue
}
for (child in children) {
if (safScanCancel) { if (safScanCancel) {
updateSafScanProgress { it.isComplete = true } updateSafScanProgress { it.isComplete = true }
val result = JSONObject() val result = JSONObject()
@@ -725,24 +921,44 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString() return result.toString()
} }
if (child.isDirectory) { try {
val childName = child.name ?: continue if (child.isDirectory) {
val childPath = if (path.isBlank()) childName else "$path/$childName" val childName = child.name ?: continue
queue.add(child to childPath) val childPath = if (path.isBlank()) childName else "$path/$childName"
} else if (child.isFile) { val childUri = child.uri.toString()
val name = child.name ?: continue if (childUri == dirUri || visitedDirUris.contains(childUri)) {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) continue
if (ext.isNotBlank() && supportedExt.contains(".$ext")) { }
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() val uriStr = child.uri.toString()
val lastModified = child.lastModified()
currentUris.add(uriStr) currentUris.add(uriStr)
// Check if file is new or modified val name = child.name ?: continue
val existingModified = existingFiles[uriStr] val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
if (existingModified == null || existingModified != lastModified) { if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
audioFiles.add(Triple(child, path, lastModified)) val existingModified = existingFiles[uriStr]
val lastModified = try {
child.lastModified()
} catch (_: Exception) {
existingModified ?: 0L
}
// Check if file is new or modified
if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified))
}
} }
} }
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: skipped child under $dirUri: ${e.message}",
)
} }
} }
} }
@@ -772,7 +988,7 @@ class MainActivity: FlutterFragmentActivity() {
val results = JSONArray() val results = JSONArray()
var scanned = 0 var scanned = 0
var errors = 0 var errors = traversalErrors
for ((doc, _, lastModified) in audioFiles) { for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) { if (safScanCancel) {
@@ -786,14 +1002,22 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString() return result.toString()
} }
val name = doc.name ?: "" val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress { updateSafScanProgress {
it.currentFile = name it.currentFile = name
} }
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val tempPath = copyUriToTemp(doc.uri, fallbackExt) val tempPath = try {
copyUriToTemp(doc.uri, fallbackExt)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: failed to copy ${doc.uri}: ${e.message}",
)
null
}
if (tempPath == null) { if (tempPath == null) {
errors++ errors++
} else { } else {
@@ -801,9 +1025,10 @@ class MainActivity: FlutterFragmentActivity() {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) { if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson) val obj = JSONObject(metadataJson)
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
obj.put("filePath", doc.uri.toString()) obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified) obj.put("fileModTime", safeLastModified)
obj.put("lastModified", lastModified) obj.put("lastModified", safeLastModified)
results.put(obj) results.put(obj)
} else { } else {
errors++ errors++
+1 -1
View File
@@ -47,7 +47,7 @@ var (
func GetDeezerClient() *DeezerClient { func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() { deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{ deezerClient = &DeezerClient{
httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile), httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry), searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry), albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry), artistCache: make(map[string]*cacheEntry),
-3
View File
@@ -6,11 +6,8 @@ toolchain go1.25.7
require ( require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac v1.0.0
github.com/go-flac/go-flac/v2 v2.0.4 github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2 github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
+11 -8
View File
@@ -2,18 +2,17 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
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 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 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-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
@@ -23,12 +22,14 @@ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/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 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= 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-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
@@ -45,3 +46,5 @@ 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.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+31
View File
@@ -55,6 +55,27 @@ var sharedTransport = &http.Transport{
DisableCompression: true, DisableCompression: true,
} }
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
// Isolated from download traffic so that download failures cannot poison
// the connection pool used by metadata enrichment.
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
}
var sharedClient = &http.Client{ var sharedClient = &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
Timeout: DefaultTimeout, Timeout: DefaultTimeout,
@@ -72,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
} }
} }
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: metadataTransport,
Timeout: timeout,
}
}
func GetSharedClient() *http.Client { func GetSharedClient() *http.Client {
return sharedClient return sharedClient
} }
@@ -82,6 +112,7 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() { func CloseIdleConnections() {
sharedTransport.CloseIdleConnections() sharedTransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
} }
// Also checks for ISP blocking on errors // Also checks for ISP blocking on errors
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis" "github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac/v2"
) )
type Metadata struct { type Metadata struct {
+1 -1
View File
@@ -37,7 +37,7 @@ var (
func NewSongLinkClient() *SongLinkClient { func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() { songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{ globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), client: NewMetadataHTTPClient(SongLinkTimeout),
} }
}) })
return globalSongLinkClient return globalSongLinkClient
+1 -1
View File
@@ -114,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano()) src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{ c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), httpClient: NewMetadataHTTPClient(15 * time.Second),
clientID: clientID, clientID: clientID,
clientSecret: clientSecret, clientSecret: clientSecret,
rng: rand.New(src), rng: rand.New(src),
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.5.1'; static const String version = '3.5.2';
static const String buildNumber = '75'; static const String buildNumber = '76';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+30
View File
@@ -988,6 +988,18 @@ abstract class AppLocalizations {
/// **'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'** /// **'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'**
String get aboutDabMusicDesc; String get aboutDabMusicDesc;
/// Name of SpotiSaver API service - DO NOT TRANSLATE
///
/// In en, this message translates to:
/// **'SpotiSaver'**
String get aboutSpotiSaver;
/// Credit for SpotiSaver API
///
/// In en, this message translates to:
/// **'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'**
String get aboutSpotiSaverDesc;
/// App description in header card /// App description in header card
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4396,6 +4408,24 @@ abstract class AppLocalizations {
/// **'This Year'** /// **'This Year'**
String get libraryFilterDateYear; String get libraryFilterDateYear;
/// Filter section - sort order
///
/// In en, this message translates to:
/// **'Sort'**
String get libraryFilterSort;
/// Sort option - newest first
///
/// In en, this message translates to:
/// **'Latest'**
String get libraryFilterSortLatest;
/// Sort option - oldest first
///
/// In en, this message translates to:
/// **'Oldest'**
String get libraryFilterSortOldest;
/// Badge showing number of active filters /// Badge showing number of active filters
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+16
View File
@@ -504,6 +504,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!'; 'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
@@ -2442,6 +2449,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -491,6 +491,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -2427,6 +2434,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -491,6 +491,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -2427,6 +2434,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -491,6 +491,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -2427,6 +2434,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -491,6 +491,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -2427,6 +2434,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
File diff suppressed because it is too large Load Diff
+16
View File
@@ -487,6 +487,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
@@ -2413,6 +2420,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -491,6 +491,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -2427,6 +2434,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -491,6 +491,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -2427,6 +2434,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -491,6 +491,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -2427,6 +2434,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -504,6 +504,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'Лучший API для стриминга Qobuz. Без него загрузка файлов в высоком разрешении была бы невозможна!'; 'Лучший API для стриминга Qobuz. Без него загрузка файлов в высоком разрешении была бы невозможна!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
@@ -2473,6 +2480,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -498,6 +498,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!'; 'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
@@ -2442,6 +2449,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+16
View File
@@ -491,6 +491,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -2427,6 +2434,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
+10
View File
@@ -346,6 +346,10 @@
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"}, "@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"}, "@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
"aboutSpotiSaver": "SpotiSaver",
"@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"},
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
"@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"@aboutAppDescription": {"description": "App description in header card"}, "@aboutAppDescription": {"description": "App description in header card"},
@@ -1824,6 +1828,12 @@
"@libraryFilterDateMonth": {"description": "Filter option - this month"}, "@libraryFilterDateMonth": {"description": "Filter option - this month"},
"libraryFilterDateYear": "This Year", "libraryFilterDateYear": "This Year",
"@libraryFilterDateYear": {"description": "Filter option - this year"}, "@libraryFilterDateYear": {"description": "Filter option - this year"},
"libraryFilterSort": "Sort",
"@libraryFilterSort": {"description": "Filter section - sort order"},
"libraryFilterSortLatest": "Latest",
"@libraryFilterSortLatest": {"description": "Sort option - newest first"},
"libraryFilterSortOldest": "Oldest",
"@libraryFilterSortOldest": {"description": "Sort option - oldest first"},
"libraryFilterActive": "{count} filter(s) active", "libraryFilterActive": "{count} filter(s) active",
"@libraryFilterActive": { "@libraryFilterActive": {
"description": "Badge showing number of active filters", "description": "Badge showing number of active filters",
@@ -2813,6 +2813,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(filePath.endsWith('.flac') || (filePath.endsWith('.flac') ||
(mimeType != null && mimeType.contains('flac'))); (mimeType != null && mimeType.contains('flac')));
final shouldForceTidalSafM4aHandling = final shouldForceTidalSafM4aHandling =
!wasExisting &&
isContentUriPath && isContentUriPath &&
effectiveSafMode && effectiveSafMode &&
actualService == 'tidal' && actualService == 'tidal' &&
@@ -3444,6 +3445,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType: errorType, errorType: errorType,
); );
_failedInSession++; _failedInSession++;
// Immediately cleanup connections after failure to prevent
// poisoned connection pool from affecting subsequent downloads
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
_log.e('Post-failure connection cleanup failed: $e');
}
} }
_downloadCount++; _downloadCount++;
@@ -3485,6 +3494,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType: errorType, errorType: errorType,
); );
_failedInSession++; _failedInSession++;
// Immediately cleanup connections after exception
try {
await PlatformBridge.cleanupConnections();
} catch (cleanupErr) {
_log.e('Post-exception connection cleanup failed: $cleanupErr');
}
} }
} }
} }
+386 -136
View File
@@ -1,4 +1,5 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -14,7 +15,8 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen; import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionArtistScreen;
class _AlbumCache { class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {}; static final Map<String, _CacheEntry> _cache = {};
@@ -76,29 +78,32 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Use extensionId if available, otherwise detect from albumId prefix // Use extensionId if available, otherwise detect from albumId prefix
final providerId = widget.extensionId ?? final providerId =
widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'); (widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordAlbumAccess( ref
id: widget.albumId, .read(recentAccessProvider.notifier)
name: widget.albumName, .recordAlbumAccess(
artistName: widget.tracks?.firstOrNull?.artistName, id: widget.albumId,
imageUrl: widget.coverUrl, name: widget.albumName,
providerId: providerId, artistName: widget.tracks?.firstOrNull?.artistName,
); imageUrl: widget.coverUrl,
providerId: providerId,
);
}); });
if (widget.tracks != null && widget.tracks!.isNotEmpty) { if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks; _tracks = widget.tracks;
} else { } else {
_tracks = _AlbumCache.get(widget.albumId); _tracks = _AlbumCache.get(widget.albumId);
} }
_artistId = widget.artistId; _artistId = widget.artistId;
if (_tracks == null || _tracks!.isEmpty) { if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks(); _fetchTracks();
} }
@@ -133,27 +138,32 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return date; return date;
} }
Future<void> _fetchTracks() async { Future<void> _fetchTracks() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
Map<String, dynamic> metadata; Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) { if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId); metadata = await PlatformBridge.getDeezerMetadata(
'album',
deezerAlbumId,
);
} else { } else {
final url = 'https://open.spotify.com/album/${widget.albumId}'; final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
} }
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?; final artistId = albumInfo?['artist_id'] as String?;
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
if (mounted) { if (mounted) {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
@@ -199,15 +209,19 @@ Future<void> _fetchTracks() async {
_buildAppBar(context, colorScheme), _buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme), _buildInfoCard(context, colorScheme),
if (_isLoading) if (_isLoading)
const SliverToBoxAdapter(child: Padding( const SliverToBoxAdapter(
padding: EdgeInsets.all(32), child: Padding(
child: Center(child: CircularProgressIndicator()), padding: EdgeInsets.all(32),
)), child: Center(child: CircularProgressIndicator()),
if (_error != null) ),
SliverToBoxAdapter(child: Padding( ),
padding: const EdgeInsets.all(16), if (_error != null)
child: _buildErrorWidget(_error!, colorScheme), SliverToBoxAdapter(
)), child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme), _buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks),
@@ -221,7 +235,7 @@ Future<void> _fetchTracks() async {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; final coverSize = screenWidth * 0.5;
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: 320,
pinned: true, pinned: true,
@@ -244,9 +258,10 @@ Future<void> _fetchTracks() async {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
collapseMode: CollapseMode.none, collapseMode: CollapseMode.none,
background: Stack( background: Stack(
@@ -258,25 +273,35 @@ Future<void> _fetchTracks() async {
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface), placeholder: (_, _) =>
errorWidget: (_, _, _) => Container(color: colorScheme.surface), Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
) )
else else
Container(color: colorScheme.surface), Container(color: colorScheme.surface),
ClipRect( ClipRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)), child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
), ),
), ),
Positioned( Positioned(
left: 0, right: 0, bottom: 0, height: 80, left: 0,
right: 0,
bottom: 0,
height: 80,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface], colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
), ),
), ),
), ),
@@ -303,15 +328,19 @@ Future<void> _fetchTracks() async {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), child: Icon(
Icons.album,
size: 64,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -320,7 +349,10 @@ Future<void> _fetchTracks() async {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
@@ -328,7 +360,7 @@ Future<void> _fetchTracks() async {
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8), color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
@@ -338,18 +370,20 @@ Future<void> _fetchTracks() async {
); );
} }
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? []; final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -357,7 +391,10 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
children: [ children: [
Text( Text(
widget.albumName, widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
if (artistName != null && artistName.isNotEmpty) ...[ if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -378,27 +415,61 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
runSpacing: 8, runSpacing: 8,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer), Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
context.l10n.tracksCount(tracks.length),
style: TextStyle(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
if (releaseDate != null && releaseDate.isNotEmpty) if (releaseDate != null && releaseDate.isNotEmpty)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer), Icon(
Icons.calendar_today,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
_formatReleaseDate(releaseDate),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
@@ -412,7 +483,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
label: Text(context.l10n.downloadAllCount(tracks.length)), label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48), minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
), ),
), ),
], ],
@@ -432,28 +505,35 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
children: [ children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary), Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
], ],
), ),
), ),
); );
} }
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) { Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
) {
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { final track = tracks[index];
final track = tracks[index]; return KeyedSubtree(
return KeyedSubtree( key: ValueKey(track.id),
key: ValueKey(track.id), child: _AlbumTrackItem(
child: _AlbumTrackItem( track: track,
track: track, onDownload: () => _downloadTrack(context, track),
onDownload: () => _downloadTrack(context, track), ),
), );
); }, childCount: tracks.length),
},
childCount: tracks.length,
),
); );
} }
@@ -466,13 +546,23 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
artistName: track.artistName, artistName: track.artistName,
coverUrl: track.coverUrl, coverUrl: track.coverUrl,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); .read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); .read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
} }
} }
@@ -486,27 +576,44 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
trackName: '${tracks.length} tracks', trackName: '${tracks.length} tracks',
artistName: widget.albumName, artistName: widget.albumName,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); .read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); .read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
} }
} }
void _navigateToArtist(BuildContext context, String artistName) { void _navigateToArtist(BuildContext context, String artistName) {
final artistId = _artistId ?? final artistId =
_artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown'); (widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) { if (artistId == 'unknown' ||
artistId == 'deezer:unknown' ||
artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')), SnackBar(content: Text('Artist information not available')),
); );
return; return;
} }
if (widget.extensionId != null) { if (widget.extensionId != null) {
Navigator.push( Navigator.push(
context, context,
@@ -521,7 +628,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
); );
return; return;
} }
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -535,10 +642,11 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
} }
Widget _buildErrorWidget(String error, ColorScheme colorScheme) { Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') || final isRateLimit =
error.toLowerCase().contains('rate limit') || error.contains('429') ||
error.toLowerCase().contains('too many requests'); error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) { if (isRateLimit) {
return Card( return Card(
elevation: 0, elevation: 0,
@@ -577,7 +685,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
), ),
); );
} }
return Card( return Card(
elevation: 0, elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5), color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -588,7 +696,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
children: [ children: [
Icon(Icons.error_outline, color: colorScheme.error), Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))), Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
], ],
), ),
), ),
@@ -605,33 +715,44 @@ class _AlbumTrackItem extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch( final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), downloadQueueLookupProvider.select(
(lookup) => lookup.byTrackId[track.id],
),
); );
final isInHistory = ref.watch(downloadHistoryProvider.select((state) { final isInHistory = ref.watch(
return state.isDownloaded(track.id); downloadHistoryProvider.select((state) {
})); return state.isDownloaded(track.id);
}),
final settings = ref.watch(settingsProvider); );
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator final showLocalLibraryIndicator = ref.watch(
? ref.watch(localLibraryProvider.select((state) => settingsProvider.select(
state.existsInLibrary( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
isrc: track.isrc, ),
trackName: track.name, );
artistName: track.artistName, final isInLocalLibrary = showLocalLibraryIndicator
))) ? ref.watch(
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: false; : false;
final isQueued = queueItem != null; final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading; final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing; final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed; final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0; final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory); final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -639,8 +760,10 @@ class _AlbumTrackItem extends ConsumerWidget {
elevation: 0, elevation: 0,
color: Colors.transparent, color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: SizedBox( leading: SizedBox(
width: 32, width: 32,
child: Center( child: Center(
@@ -653,14 +776,31 @@ child: ListTile(
), ),
), ),
), ),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), title: Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row( subtitle: Row(
children: [ children: [
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))), Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary) ...[ if (isInLocalLibrary) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.tertiaryContainer, color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -668,51 +808,102 @@ child: ListTile(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer), Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3), const SizedBox(width: 3),
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)), Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
], ],
), ),
), ),
], ],
], ],
), ),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress), trailing: _buildDownloadButton(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), context,
ref,
colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
), ),
), ),
); );
} }
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async { void _handleTap(
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return; if (isQueued) return;
if (isInLocalLibrary) { if (isInLocalLibrary) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
} }
return; return;
} }
if (isInHistory) { if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) { if (historyItem != null) {
final exists = await fileExists(historyItem.filePath); final exists = await fileExists(historyItem.filePath);
if (exists) { if (exists) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
} }
return; return;
} else { } else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
} }
} }
} }
onDownload(); onDownload();
} }
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { Widget _buildDownloadButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued, required bool isQueued,
required bool isDownloading, required bool isDownloading,
required bool isFinalizing, required bool isFinalizing,
@@ -723,11 +914,29 @@ child: ListTile(
}) { }) {
const double size = 44.0; const double size = 44.0;
const double iconSize = 20.0; const double iconSize = 20.0;
if (showAsDownloaded) { if (showAsDownloaded) {
return GestureDetector( return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), onTap: () => _handleTap(
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)), context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
); );
} else if (isFinalizing) { } else if (isFinalizing) {
return SizedBox( return SizedBox(
@@ -736,7 +945,11 @@ child: ListTile(
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
], ],
), ),
@@ -748,17 +961,54 @@ child: ListTile(
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)), value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
], ],
), ),
); );
} else if (isQueued) { } else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize)); return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else { } else {
return GestureDetector( return GestureDetector(
onTap: onDownload, onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
); );
} }
} }
File diff suppressed because it is too large Load Diff
+1440 -1051
View File
File diff suppressed because it is too large Load Diff
+357 -108
View File
@@ -1,4 +1,5 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -56,26 +57,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Future<void> _fetchTracksIfNeeded() async { Future<void> _fetchTracksIfNeeded() async {
if (widget.tracks.isNotEmpty || widget.playlistId == null) return; if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
}); });
try { try {
// Extract numeric ID from "deezer:123" format // Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!; String playlistId = widget.playlistId!;
if (playlistId.startsWith('deezer:')) { if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7); playlistId = playlistId.substring(7);
} }
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); final result = await PlatformBridge.getDeezerMetadata(
'playlist',
playlistId,
);
if (!mounted) return; if (!mounted) return;
// Go backend returns 'track_list' not 'tracks' // Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? []; final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
setState(() { setState(() {
_fetchedTracks = tracks; _fetchedTracks = tracks;
_isLoading = false; _isLoading = false;
@@ -97,7 +103,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else if (durationValue is double) { } else if (durationValue is double) {
durationMs = durationValue.toInt(); durationMs = durationValue.toInt();
} }
return Track( return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(), id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(), name: (data['name'] ?? '').toString(),
@@ -141,12 +147,13 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width final coverSize = screenWidth * 0.5; // 50% of screen width
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: 320,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: AnimatedOpacity( title: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -164,9 +171,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
collapseMode: CollapseMode.none, collapseMode: CollapseMode.none,
background: Stack( background: Stack(
@@ -178,25 +186,35 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface), placeholder: (_, _) =>
errorWidget: (_, _, _) => Container(color: colorScheme.surface), Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
) )
else else
Container(color: colorScheme.surface), Container(color: colorScheme.surface),
ClipRect( ClipRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)), child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
), ),
), ),
Positioned( Positioned(
left: 0, right: 0, bottom: 0, height: 80, left: 0,
right: 0,
bottom: 0,
height: 80,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface], colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
), ),
), ),
), ),
@@ -224,15 +242,19 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant), child: Icon(
Icons.playlist_play,
size: 64,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -241,17 +263,20 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8), color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -266,34 +291,63 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), Text(
widget.playlistName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer), Icon(
Icons.playlist_play,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.tracksCount(_tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
context.l10n.tracksCount(_tracks.length),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: _tracks.isEmpty ? null : () => _downloadAll(context), onPressed: _tracks.isEmpty
? null
: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18), icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(_tracks.length)), label: Text(context.l10n.downloadAllCount(_tracks.length)),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48), minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
), ),
), ),
], ],
@@ -312,7 +366,13 @@ const SizedBox(height: 16),
children: [ children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary), Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
], ],
), ),
), ),
@@ -328,7 +388,7 @@ const SizedBox(height: 16),
), ),
); );
} }
if (_error != null) { if (_error != null) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -341,7 +401,12 @@ const SizedBox(height: 16),
children: [ children: [
Icon(Icons.error_outline, color: colorScheme.error), Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))), Expanded(
child: Text(
_error!,
style: TextStyle(color: colorScheme.error),
),
),
], ],
), ),
), ),
@@ -349,7 +414,7 @@ const SizedBox(height: 16),
), ),
); );
} }
if (_tracks.isEmpty) { if (_tracks.isEmpty) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -363,21 +428,18 @@ const SizedBox(height: 16),
), ),
); );
} }
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { final track = _tracks[index];
final track = _tracks[index]; return KeyedSubtree(
return KeyedSubtree( key: ValueKey(track.id),
key: ValueKey(track.id), child: _PlaylistTrackItem(
child: _PlaylistTrackItem( track: track,
track: track, onDownload: () => _downloadTrack(context, track),
onDownload: () => _downloadTrack(context, track), ),
), );
); }, childCount: _tracks.length),
},
childCount: _tracks.length,
),
); );
} }
@@ -390,13 +452,23 @@ const SizedBox(height: 16),
artistName: track.artistName, artistName: track.artistName,
coverUrl: track.coverUrl, coverUrl: track.coverUrl,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); .read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); .read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
} }
} }
@@ -409,13 +481,29 @@ const SizedBox(height: 16),
trackName: '${_tracks.length} tracks', trackName: '${_tracks.length} tracks',
artistName: widget.playlistName, artistName: widget.playlistName,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length)))); .read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
),
);
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length)))); .read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
),
);
} }
} }
} }
@@ -430,34 +518,45 @@ class _PlaylistTrackItem extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch( final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), downloadQueueLookupProvider.select(
(lookup) => lookup.byTrackId[track.id],
),
); );
final isInHistory = ref.watch(downloadHistoryProvider.select((state) { final isInHistory = ref.watch(
return state.isDownloaded(track.id); downloadHistoryProvider.select((state) {
})); return state.isDownloaded(track.id);
}),
);
// Check local library for duplicate detection // Check local library for duplicate detection
final settings = ref.watch(settingsProvider); final showLocalLibraryIndicator = ref.watch(
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates; settingsProvider.select(
final isInLocalLibrary = showLocalLibraryIndicator (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
? ref.watch(localLibraryProvider.select((state) => ),
state.existsInLibrary( );
isrc: track.isrc, final isInLocalLibrary = showLocalLibraryIndicator
trackName: track.name, ? ref.watch(
artistName: track.artistName, localLibraryProvider.select(
))) (state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: false; : false;
final isQueued = queueItem != null; final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading; final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing; final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed; final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0; final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory); final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -466,18 +565,58 @@ class _PlaylistTrackItem extends ConsumerWidget {
color: Colors.transparent, color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
leading: track.coverUrl != null borderRadius: BorderRadius.circular(12),
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, 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)), leading: track.coverUrl != null
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), ? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
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,
),
),
title: Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row( subtitle: Row(
children: [ children: [
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))), Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary) ...[ if (isInLocalLibrary) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.tertiaryContainer, color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -485,51 +624,102 @@ leading: track.coverUrl != null
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer), Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3), const SizedBox(width: 3),
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)), Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
], ],
), ),
), ),
], ],
], ],
), ),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress), trailing: _buildDownloadButton(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), context,
ref,
colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
), ),
), ),
); );
} }
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async { void _handleTap(
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return; if (isQueued) return;
if (isInLocalLibrary) { if (isInLocalLibrary) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
} }
return; return;
} }
if (isInHistory) { if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) { if (historyItem != null) {
final exists = await fileExists(historyItem.filePath); final exists = await fileExists(historyItem.filePath);
if (exists) { if (exists) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
} }
return; return;
} else { } else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
} }
} }
} }
onDownload(); onDownload();
} }
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { Widget _buildDownloadButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued, required bool isQueued,
required bool isDownloading, required bool isDownloading,
required bool isFinalizing, required bool isFinalizing,
@@ -540,11 +730,29 @@ leading: track.coverUrl != null
}) { }) {
const double size = 44.0; const double size = 44.0;
const double iconSize = 20.0; const double iconSize = 20.0;
if (showAsDownloaded) { if (showAsDownloaded) {
return GestureDetector( return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), onTap: () => _handleTap(
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)), context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
); );
} else if (isFinalizing) { } else if (isFinalizing) {
return SizedBox( return SizedBox(
@@ -553,7 +761,11 @@ leading: track.coverUrl != null
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
], ],
), ),
@@ -565,17 +777,54 @@ leading: track.coverUrl != null
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)), value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
], ],
), ),
); );
} else if (isQueued) { } else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize)); return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else { } else {
return GestureDetector( return GestureDetector(
onTap: onDownload, onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
); );
} }
} }
+685 -366
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -123,6 +123,13 @@ class AboutPage extends StatelessWidget {
title: context.l10n.aboutDabMusic, title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc, subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'), onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutSpotiSaver,
subtitle: context.l10n.aboutSpotiSaverDesc,
onTap: () => _launchUrl('https://spotisaver.net'),
showDivider: false, showDivider: false,
), ),
], ],
+93 -77
View File
@@ -54,47 +54,6 @@ class DonatePage extends StatelessWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
// Header message
Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(
Icons.favorite_rounded,
size: 48,
color: colorScheme.primary,
),
const SizedBox(height: 12),
Text(
'Support SpotiFLAC-Mobile',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'SpotiFLAC-Mobile is free and open source. '
'If you enjoy using it, consider supporting '
'the development.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
),
const SizedBox(height: 16),
// Donate links card // Donate links card
_DonateLinksCard(colorScheme: colorScheme), _DonateLinksCard(colorScheme: colorScheme),
@@ -103,57 +62,83 @@ class DonatePage extends StatelessWidget {
// Recent donors section // Recent donors section
_RecentDonorsCard(colorScheme: colorScheme), _RecentDonorsCard(colorScheme: colorScheme),
const SizedBox(height: 12), const SizedBox(height: 16),
// Notice // Combined notice card
Card( Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Row(
Icons.info_outline_rounded, children: [
size: 20, Icon(
color: colorScheme.onSurfaceVariant, Icons.volunteer_activism_rounded,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Good to Know',
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
), ),
const SizedBox(width: 12), const SizedBox(height: 10),
Expanded( _NoticeLine(
child: Column( icon: Icons.block,
crossAxisAlignment: CrossAxisAlignment.start, text: 'Not selling early access, premium features, or paywalls',
children: [ colorScheme: colorScheme,
Text( ),
'About Supporters', const SizedBox(height: 6),
style: Theme.of(context).textTheme.titleSmall _NoticeLine(
?.copyWith( icon: Icons.build_outlined,
fontWeight: FontWeight.w600, text: 'Funds go to dev tools & testing devices',
color: colorScheme.onSurface, colorScheme: colorScheme,
), ),
), const SizedBox(height: 6),
const SizedBox(height: 6), _NoticeLine(
Text( icon: Icons.favorite_border,
'By supporting SpotiFLAC, you become part of this app\'s history. ' text: 'Your support is the only way to keep this project alive',
'Your name will remain in this version permanently as a token of appreciation. ' colorScheme: colorScheme,
'The supporter list is updated manually each month and embedded directly in the app ' ),
'-- no remote server is used. Even if your support period ends, your name stays in ' Divider(
'every version it was included in.', height: 24,
style: Theme.of(context).textTheme.bodySmall color: colorScheme.outlineVariant.withValues(alpha: 0.3),
?.copyWith( ),
color: colorScheme.onSurfaceVariant, _NoticeLine(
), icon: Icons.history,
), text: 'Your name stays permanently in every version it was included in',
], colorScheme: colorScheme,
), ),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.update,
text: 'Supporter list is updated monthly and embedded in the app',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.cloud_off,
text: 'No remote server -- everything is stored locally',
colorScheme: colorScheme,
), ),
], ],
), ),
), ),
), ),
], ],
), ),
), ),
@@ -417,3 +402,34 @@ class _DonorTile extends StatelessWidget {
); );
} }
} }
class _NoticeLine extends StatelessWidget {
final IconData icon;
final String text;
final ColorScheme colorScheme;
const _NoticeLine({
required this.icon,
required this.text,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.5.1+75 version: 3.5.2+76
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0