mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9dd82010f | |||
| f0790b627d | |||
| 55350fffa0 | |||
| 7229602343 | |||
| 1c81c53699 | |||
| 5256d6197b | |||
| 79a6c8cdc0 | |||
| aa3b4d7d1e | |||
| cd220a4650 | |||
| d71b2a9ab8 | |||
| a2efe7243d | |||
| e0acda14e4 | |||
| 029ab8ea47 | |||
| 38f9498006 | |||
| af203ae51f |
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/f6ba8fa4a572d69f6196f980733089cb741088e3ceb49d0bd3ceda5a694a2466/)
|
[](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](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">
|
[](https://t.me/spotiflac)
|
||||||
<a href="https://t.me/spotiflac">
|
[](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++
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -496,6 +496,13 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
String get aboutDabMusicDesc =>
|
String get aboutDabMusicDesc =>
|
||||||
'API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!';
|
'API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!';
|
||||||
|
|
||||||
|
@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 =>
|
||||||
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
|
||||||
@@ -2440,6 +2447,15 @@ class AppLocalizationsId 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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+356
-106
@@ -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 = {};
|
||||||
@@ -81,15 +83,18 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
|
|
||||||
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) {
|
||||||
@@ -133,21 +138,26 @@ 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?;
|
||||||
@@ -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),
|
||||||
@@ -244,7 +258,8 @@ 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(
|
||||||
@@ -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,7 +328,7 @@ 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(),
|
||||||
@@ -311,7 +336,11 @@ Future<void> _fetchTracks() async {
|
|||||||
)
|
)
|
||||||
: 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,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -338,7 +370,7 @@ 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;
|
||||||
@@ -349,7 +381,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
|||||||
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,21 +576,38 @@ 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')),
|
||||||
);
|
);
|
||||||
@@ -535,9 +642,10 @@ 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(
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -607,22 +717,32 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
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 = ref.watch(
|
||||||
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
|
settingsProvider.select(
|
||||||
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||||
|
),
|
||||||
|
);
|
||||||
final isInLocalLibrary = showLocalLibraryIndicator
|
final isInLocalLibrary = showLocalLibraryIndicator
|
||||||
? ref.watch(localLibraryProvider.select((state) =>
|
? ref.watch(
|
||||||
state.existsInLibrary(
|
localLibraryProvider.select(
|
||||||
isrc: track.isrc,
|
(state) => state.existsInLibrary(
|
||||||
trackName: track.name,
|
isrc: track.isrc,
|
||||||
artistName: track.artistName,
|
trackName: track.name,
|
||||||
)))
|
artistName: track.artistName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
final isQueued = queueItem != null;
|
final isQueued = queueItem != null;
|
||||||
@@ -631,7 +751,8 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
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,43 +808,91 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,7 +900,10 @@ child: ListTile(
|
|||||||
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,
|
||||||
@@ -726,8 +917,26 @@ child: ListTile(
|
|||||||
|
|
||||||
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+548
-292
File diff suppressed because it is too large
Load Diff
+1276
-887
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||||
@@ -69,12 +70,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
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;
|
||||||
@@ -146,7 +152,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
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,7 +171,8 @@ 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(
|
||||||
@@ -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,7 +242,7 @@ 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(),
|
||||||
@@ -232,7 +250,11 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
)
|
)
|
||||||
: 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,7 +263,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
stretchModes: const [
|
||||||
|
StretchMode.zoomBackground,
|
||||||
|
StretchMode.blurBackground,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -365,19 +430,16 @@ 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,23 +520,33 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
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(
|
||||||
|
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
|
||||||
|
),
|
||||||
|
);
|
||||||
final isInLocalLibrary = showLocalLibraryIndicator
|
final isInLocalLibrary = showLocalLibraryIndicator
|
||||||
? ref.watch(localLibraryProvider.select((state) =>
|
? ref.watch(
|
||||||
state.existsInLibrary(
|
localLibraryProvider.select(
|
||||||
isrc: track.isrc,
|
(state) => state.existsInLibrary(
|
||||||
trackName: track.name,
|
isrc: track.isrc,
|
||||||
artistName: track.artistName,
|
trackName: track.name,
|
||||||
)))
|
artistName: track.artistName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
final isQueued = queueItem != null;
|
final isQueued = queueItem != null;
|
||||||
@@ -457,7 +555,8 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
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,43 +624,91 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -529,7 +716,10 @@ leading: track.coverUrl != null
|
|||||||
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,
|
||||||
@@ -543,8 +733,26 @@ leading: track.coverUrl != null
|
|||||||
|
|
||||||
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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user