Compare commits

...

21 Commits

Author SHA1 Message Date
zarzet 3a6b7eed59 perf: swap SpotubeDL as primary YouTube provider, Cobalt as fallback 2026-02-10 00:47:44 +07:00
zarzet 51d02d7764 chore: bump app_info version to 3.6.0+77 2026-02-09 23:36:34 +07:00
zarzet df39d61ed4 feat: save cover art, save lyrics, re-enrich metadata with full SAF support + YouTube Cobalt provider with SpotubeDL fallback + metadata summary logging 2026-02-09 23:07:18 +07:00
zarzet 7ec5d28caf feat: add YouTube provider for lossy downloads via Cobalt API
- New YouTube download provider with Opus 256kbps and MP3 320kbps options
- SongLink/Odesli integration for Spotify/Deezer ID to YouTube URL conversion
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during download
- Queue progress shows bytes (X.X MB) for streaming downloads
- Full metadata embedding: cover, lyrics, title, artist, album, track#, disc#, year, ISRC
- Removed Tidal HIGH (lossy AAC) option - use YouTube for lossy instead
- Bumped version to 3.6.0
2026-02-09 18:15:43 +07:00
zarzet 23f5aa11b0 feat: responsive layout tuning, cache management page, and improved recent access UX
- Add responsive scaling across album, artist, playlist, downloaded album, local album, queue, setup, and tutorial screens to prevent overflow on smaller devices
- Add new Storage & Cache management page (Settings > Storage & Cache) with per-category clear and cleanup actions
- Extract normalizedHeaderTopPadding utility for consistent app bar padding
- Improve home search Recent Access behavior: show when focused with empty input, hide stale results during active recent mode
- Add excluded-downloaded-count tracking to local library scan stats
- Add recentEmpty and recentShowAllDownloads l10n keys (EN + ID)
- Add full cache management l10n keys (EN + ID)
- Fix about_page indentation and formatting consistency
- Fix appearance_settings_page formatting
- Fix downloaded_album_screen and local_album_screen formatting and responsive sizing
2026-02-09 15:58:50 +07:00
zarzet 5fdf1df5df feat: cross-script transliteration matching for Tidal/Qobuz and skip-downloaded option for CSV import 2026-02-09 10:57:52 +07:00
zarzet f9dd82010f fix: skip M4A conversion for existing files and prevent empty SAF folders on duplicates 2026-02-08 15:44:05 +07:00
zarzet f0790b627d perf: optimize album, artist, and playlist screens
- Scope settingsProvider watches with select() for localLibrary flags

- Wrap popular track items in Consumer for scoped provider watches

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

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

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

- Memoize filter/sort computations per build pass

- Isolate queue header/list into dedicated Consumer slivers

- Fix Positioned/ValueListenableBuilder nesting order in grid view
2026-02-08 14:20:18 +07:00
zarzet 7229602343 feat: replace date filter with sorting (latest/oldest/A-Z/Z-A)
- Remove broken date range filter (today/week/month/year)
- Add sort options: Latest, Oldest, A-Z, Z-A
- Sorting applies to tracks (all/singles tabs) and albums tab
- Add l10n keys for sort labels
2026-02-08 13:44:02 +07:00
zarzet 1c81c53699 fix: library filters now apply to date/albums and update tab counts
- Remove redundant manual export button from queue header
- Add date range filtering support for local library items
- Apply advanced filters (date, quality, format, source) to album tab
- Tab chip counts (All/Albums/Singles) now reflect filtered results
- Extract reusable filter helpers: _passesDateFilter, _passesQualityFilter, _passesFormatFilter
- Add _filterGroupedAlbums and _filterGroupedLocalAlbums methods
2026-02-08 13:09:19 +07:00
zarzet 5256d6197b fix: metadata enrichment bug and upgrade go-flac to v2
- Fix metadata enrichment bug where failed downloads poison connection pool
  - Create separate metadataTransport for Deezer API calls
  - Add immediate connection cleanup after download failures
- Fix Samsung One UI local library scan with MediaStore fallback
- Fix 'In Library' tracks still showing as downloadable
- Upgrade go-flac packages to v2 (flacpicture v2.0.2, flacvorbis v2.0.2, go-flac v2.0.4)
- Update CHANGELOG.md v3.5.2
2026-02-08 12:01:08 +07:00
Zarz Eleutherius 79a6c8cdc0 Merge pull request #139 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-08 08:31:29 +07:00
renovate[bot] aa3b4d7d1e fix(deps): update go dependencies to v2 2026-02-07 21:39:25 +00:00
zarzet cd220a4650 merge: sync main into dev (README updates) 2026-02-08 02:51:05 +07:00
Zarz Eleutherius d71b2a9ab8 Update README to remove Search Source and enhance Telegram links 2026-02-08 02:48:29 +07:00
zarzet a2efe7243d docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:36:15 +07:00
zarzet e0acda14e4 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:33:56 +07:00
Zarz Eleutherius 029ab8ea47 Update VirusTotal badge link in README 2026-02-08 02:30:22 +07:00
zarzet 38f9498006 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:26:27 +07:00
Zarz Eleutherius af203ae51f Update VirusTotal badge link in README 2026-02-07 14:44:19 +07:00
70 changed files with 16348 additions and 6376 deletions
+119
View File
@@ -1,5 +1,124 @@
# Changelog # Changelog
## [3.6.0] - 2026-02-09
### Highlights
- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services
- Opus 256kbps (recommended) or MP3 320kbps quality options
- Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC
- Lyrics fetching from lrclib.net with embed and external .lrc support
- Works as fallback when Tidal/Qobuz/Amazon downloads fail
- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus)
- Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC
- Advanced fields: Label, Copyright, Composer, Comment
- FLAC: native Go writer, MP3/Opus: FFmpeg-based writer
- UI refreshes in-place after save without needing to re-open the screen
- iOS and Android support
### Added
- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen
- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song
- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg)
- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file
- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion
- SpotubeDL as fallback Cobalt proxy when primary API fails
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during YouTube download
- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode)
- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC
### Changed
- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead
- Simplified download service picker by removing dead lossy format code
- Removed Amazon from download settings UI (now only used as automatic fallback)
- Cleaned up dead disabled-chip code in download service selector
### Fixed
- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests
- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility
- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests
- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups
- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding)
- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files
- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly)
- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus)
- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath`
- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard
- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading
- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label)
---
## [3.5.3] - 2026-02-09
### Added
- CSV import flow now includes a new option: **Skip already downloaded songs** before enqueueing tracks
- Added regression test suite for cross-script matching behavior in Go backend (`go_backend/matching_test.go`)
### Changed
- CSV import confirmation dialog now supports filtering out tracks already present in download history (matched by Spotify ID and ISRC)
- CSV import enqueue feedback now reports added/skipped counts when duplicate downloads are skipped
- Home search now prioritizes **Recent Access** when search field is focused with empty input, even if old search results still exist in memory
- Search filter/result sections are now hidden while Recent Access mode is active to avoid stale-result overlap
- Recent Access now shows a localized empty-state message when no recent items are available
- Normalized collapsing AppBar top inset across iOS/Android so header height/animation stays visually consistent on Apple devices
- Storage & Cache UX improved: `Clear all cache` now preserves web/runtime cache by default (optional), with explicit warnings/actions for runtime cache resets
- Local library settings now include a display count for tracks excluded because they already exist in download history
- Responsive layout tuning applied across key screens to reduce hardcoded-height overflow issues on smaller devices
### Fixed
- Fixed false-positive cross-script matching in Qobuz/Tidal where unrelated titles/artists in different scripts could be incorrectly accepted
- Cross-script title/artist matching now requires transliteration-aware normalization and strict similarity checks instead of auto-accepting script differences
- Qobuz metadata fallback no longer scans all results when zero title matches are found; title verification is now required
- Qobuz metadata final validation now rejects results when title does not match expected track name
- Fixed Home search regression where Recent Access panel could disappear after previous searches
- Fixed Local Library card/layout crash caused by `Flex` usage under unbounded height constraints
- Hardened FFmpeg metadata embedding temp-file naming to prevent rare collisions during parallel downloads/fallback flows (Qobuz → Tidal) that could cause missing embedded metadata
- Fixed SAF external lyrics naming where some providers saved `.lrc` files as `.lrc.txt`; LRC export now uses neutral MIME to preserve `.lrc` extension
## [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
+13 -19
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/f6ba8fa4a572d69f6196f980733089cb741088e3ceb49d0bd3ceda5a694a2466/) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center"> <div align="center">
@@ -24,15 +24,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
<img src="assets/images/4.jpg?v=2" width="200" /> <img src="assets/images/4.jpg?v=2" width="200" />
</p> </p>
## Search Source
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Extensions** | Install additional search providers from the Store |
## Extensions ## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently. Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
@@ -54,15 +45,8 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
## Telegram ## Telegram
<p align="center"> [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
<a href="https://t.me/spotiflac"> [![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflac_chat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## FAQ ## FAQ
@@ -108,6 +92,16 @@ 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)
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
- **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++
@@ -1372,7 +1597,182 @@ class MainActivity: FlutterFragmentActivity() {
"readFileMetadata" -> { "readFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.readFileMetadata(filePath) try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.readFileMetadata(tempPath)
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.readFileMetadata(filePath)
}
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "readFileMetadata failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"editFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
val raw = Gobackend.editFileMetadata(tempPath, metadataJson)
val obj = JSONObject(raw)
val method = obj.optString("method", "")
if (method == "ffmpeg") {
// MP3/Opus: Dart needs to FFmpeg the temp file, then call writeTempToSaf
obj.put("temp_path", tempPath)
obj.put("saf_uri", filePath)
return@withContext obj.toString()
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
}
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
throw e
}
} else {
Gobackend.editFileMetadata(filePath, metadataJson)
}
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "editFileMetadata failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"writeTempToSaf" -> {
val tempPath = call.argument<String>("temp_path") ?: ""
val safUri = call.argument<String>("saf_uri") ?: ""
val response = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(safUri)
if (writeUriFromPath(uri, tempPath)) {
"""{"success":true}"""
} else {
"""{"success":false,"error":"Failed to write back to SAF"}"""
}
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
}
result.success(response)
}
"downloadCoverToFile" -> {
val coverUrl = call.argument<String>("cover_url") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val maxQuality = call.argument<Boolean>("max_quality") ?: true
val response = withContext(Dispatchers.IO) {
try {
Gobackend.downloadCoverToFile(coverUrl, outputPath, maxQuality)
"""{"success":true}"""
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"extractCoverToFile" -> {
val audioPath = call.argument<String>("audio_path") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
if (audioPath.startsWith("content://")) {
val uri = Uri.parse(audioPath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"success":false,"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.extractCoverToFile(tempPath, outputPath)
"""{"success":true}"""
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.extractCoverToFile(audioPath, outputPath)
"""{"success":true}"""
}
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"fetchAndSaveLyrics" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
val durationMs = call.argument<Long>("duration_ms") ?: 0L
val outputPath = call.argument<String>("output_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath)
"""{"success":true}"""
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"reEnrichFile" -> {
val requestJson = call.argument<String>("request_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
val reqObj = JSONObject(requestJson)
val filePath = reqObj.optString("file_path", "")
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
// Replace file_path with temp path for Go
reqObj.put("file_path", tempPath)
val raw = Gobackend.reEnrichFile(reqObj.toString())
val obj = JSONObject(raw)
if (obj.has("error")) {
return@withContext raw
}
val method = obj.optString("method", "")
if (method == "ffmpeg") {
// MP3/Opus: Dart handles FFmpeg on temp file, then writes back
obj.put("temp_path", tempPath)
obj.put("saf_uri", filePath)
return@withContext obj.toString()
// temp file NOT deleted - Dart cleans up after FFmpeg + writeTempToSaf
}
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
throw e
}
} else {
Gobackend.reEnrichFile(requestJson)
}
} catch (e: Exception) {
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
} }
result.success(response) result.success(response)
} }
@@ -1702,6 +2102,15 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"downloadFromYouTube" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadFromYouTube(json)
}
}
result.success(response)
}
"enrichTrackWithExtension" -> { "enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: "" val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}" val trackJson = call.argument<String>("track") ?: "{}"
@@ -2024,7 +2433,22 @@ class MainActivity: FlutterFragmentActivity() {
"readAudioMetadata" -> { "readAudioMetadata" -> {
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.readAudioMetadataJSON(filePath) try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.readAudioMetadataJSON(tempPath)
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.readAudioMetadataJSON(filePath)
}
} catch (e: Exception) {
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
} }
result.success(response) result.success(response)
} }
+68
View File
@@ -23,6 +23,10 @@ type AudioMetadata struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
Label string
Copyright string
Composer string
Comment string
} }
// MP3Quality represents MP3 specific quality info // MP3Quality represents MP3 specific quality info
@@ -171,6 +175,12 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber = parseTrackNumber(value)
case "TPA": case "TPA":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "TCM":
metadata.Composer = value
case "TPB":
metadata.Label = value
case "TCR":
metadata.Copyright = value
} }
pos += 6 + frameSize pos += 6 + frameSize
@@ -277,6 +287,16 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "TSRC": case "TSRC":
metadata.ISRC = value metadata.ISRC = value
case "TCOM":
metadata.Composer = value
case "TPUB":
metadata.Label = value
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractCommentFrame(frameData); v != "" {
metadata.Comment = v
}
} }
pos += 10 + frameSize pos += 10 + frameSize
@@ -339,6 +359,46 @@ func extractTextFrame(data []byte) string {
} }
} }
// extractCommentFrame parses an ID3v2 COMM frame.
// Format: encoding(1) + language(3) + description(null-terminated) + text
func extractCommentFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
// skip 3-byte language code
rest := data[4:]
// find null terminator separating description from text
var text []byte
switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
// re-prepend encoding byte so extractTextFrame can decode properly
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
func decodeUTF16(data []byte) string { func decodeUTF16(data []byte) string {
if len(data) < 2 { if len(data) < 2 {
return "" return ""
@@ -779,6 +839,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "ISRC": case "ISRC":
metadata.ISRC = value metadata.ISRC = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
} }
} }
} }
+1 -1
View File
@@ -47,7 +47,7 @@ var (
func GetDeezerClient() *DeezerClient { func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() { deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{ deezerClient = &DeezerClient{
httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile), httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry), searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry), albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry), artistCache: make(map[string]*cacheEntry),
+612 -24
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"time" "time"
@@ -267,6 +268,24 @@ func DownloadTrack(requestJSON string) (string, error) {
} }
} }
err = amazonErr err = amazonErr
case "youtube":
youtubeResult, youtubeErr := downloadFromYouTube(req)
if youtubeErr == nil {
result = DownloadResult{
FilePath: youtubeResult.FilePath,
BitDepth: 0, // Lossy format, no bit depth
SampleRate: 0, // Lossy format
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
}
err = youtubeErr
default: default:
return errorResponse("Unknown service: " + req.Service) return errorResponse("Unknown service: " + req.Service)
} }
@@ -538,34 +557,106 @@ func CleanupConnections() {
} }
func ReadFileMetadata(filePath string) (string, error) { func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath) lower := strings.ToLower(filePath)
if err != nil { isFlac := strings.HasSuffix(lower, ".flac")
return "", fmt.Errorf("failed to read metadata: %w", err) isMp3 := strings.HasSuffix(lower, ".mp3")
} isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
quality, qualityErr := GetAudioQuality(filePath)
duration := 0
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
result := map[string]interface{}{ result := map[string]interface{}{
"title": metadata.Title, "title": "",
"artist": metadata.Artist, "artist": "",
"album": metadata.Album, "album": "",
"album_artist": metadata.AlbumArtist, "album_artist": "",
"date": metadata.Date, "date": "",
"track_number": metadata.TrackNumber, "track_number": 0,
"disc_number": metadata.DiscNumber, "disc_number": 0,
"isrc": metadata.ISRC, "isrc": "",
"lyrics": metadata.Lyrics, "lyrics": "",
"duration": duration, "genre": "",
"label": "",
"copyright": "",
"composer": "",
"comment": "",
"duration": 0,
} }
if qualityErr == nil { if isFlac {
result["bit_depth"] = quality.BitDepth metadata, err := ReadMetadata(filePath)
result["sample_rate"] = quality.SampleRate if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
} else if isMp3 {
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetMP3Quality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else if isOgg {
meta, err := ReadOggVorbisComments(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
@@ -576,6 +667,66 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// EditFileMetadata writes metadata to an audio file.
// For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
if isFlac {
trackNum := 0
discNum := 0
if v, ok := fields["track_number"]; ok && v != "" {
fmt.Sscanf(v, "%d", &trackNum)
}
if v, ok := fields["disc_number"]; ok && v != "" {
fmt.Sscanf(v, "%d", &discNum)
}
meta := Metadata{
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
Date: fields["date"],
TrackNumber: trackNum,
DiscNumber: discNum,
ISRC: fields["isrc"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
}
if err := EmbedMetadata(filePath, meta, ""); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{
"success": true,
"method": "ffmpeg",
"fields": fields,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
func SetDownloadDirectory(path string) error { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
} }
@@ -1074,6 +1225,443 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
// DownloadFromYouTube downloads a track from YouTube via Cobalt API
// This is a lossy-only provider (Opus 256kbps or MP3 320kbps)
// It does NOT participate in the lossless fallback chain
func DownloadFromYouTube(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
req.OutputPath = strings.TrimSpace(req.OutputPath)
req.OutputExt = strings.TrimSpace(req.OutputExt)
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
youtubeResult, err := downloadFromYouTube(req)
if err != nil {
return errorResponse(err.Error())
}
resp := DownloadResponse{
Success: true,
Message: "Downloaded from YouTube",
FilePath: youtubeResult.FilePath,
Service: "youtube",
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter)
func IsYouTubeURLExport(urlStr string) bool {
return IsYouTubeURL(urlStr)
}
// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter)
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
return ExtractYouTubeVideoID(urlStr)
}
// ==================== COVER & LYRICS SAVE ====================
// DownloadCoverToFile downloads cover art from URL and saves to outputPath.
// If maxQuality is true, upgrades to highest available resolution.
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
data, err := downloadCoverToMemory(coverURL, maxQuality)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Saved cover art to: %s (%d KB)\n", outputPath, len(data)/1024)
return nil
}
// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath.
func ExtractCoverToFile(audioPath string, outputPath string) error {
lower := strings.ToLower(audioPath)
var coverData []byte
var err error
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
coverData, _, err = extractOggCoverArt(audioPath)
} else {
return fmt.Errorf("unsupported audio format for cover extraction")
}
if err != nil {
return fmt.Errorf("failed to extract cover: %w", err)
}
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
return nil
}
// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file.
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error {
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return fmt.Errorf("lyrics not found: %w", err)
}
if lyrics.Instrumental {
return fmt.Errorf("track is instrumental, no lyrics available")
}
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
if lrcContent == "" {
return fmt.Errorf("failed to generate LRC content")
}
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
return nil
}
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) {
var req struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err)
}
if req.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
// When search_online is true, search for metadata from internet
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
found := false
// 1) Try Deezer first (reliable, no credentials needed)
GoLog("[ReEnrich] Trying Deezer search...\n")
deezerClient := GetDeezerClient()
{
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track")
cancel()
if err == nil && len(deezerResults.Tracks) > 0 {
track := deezerResults.Tracks[0]
GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = "deezer:" + track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Deezer search failed: %v\n", err)
}
}
// 2) Try extension metadata providers (spotify-web etc) if Deezer failed
if !found {
GoLog("[ReEnrich] Trying extension metadata providers...\n")
manager := GetExtensionManager()
extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5)
if extErr == nil && len(extTracks) > 0 {
track := extTracks[0]
GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else {
req.SpotifyID = track.ID
}
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
coverURL := track.ResolvedCoverURL()
if coverURL != "" {
req.CoverURL = coverURL
}
req.DurationMs = int64(track.DurationMS)
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
found = true
} else if extErr != nil {
GoLog("[ReEnrich] Extension search failed: %v\n", extErr)
}
}
// 3) Try Spotify built-in API as last resort (will be deprecated)
if !found {
GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
spotifyClient, spotifyErr := NewSpotifyMetadataClient()
if spotifyErr == nil {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
cancel()
if err == nil && len(results.Tracks) > 0 {
track := results.Tracks[0]
GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Spotify search failed: %v\n", err)
}
} else {
GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
}
}
// Try to get extended metadata (genre, label) from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
}
}
if !found {
GoLog("[ReEnrich] No online match found, using existing metadata\n")
}
}
// Log metadata summary before embedding
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
// Download cover art to temp file
var coverTempPath string
if req.CoverURL != "" {
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
if err != nil {
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
} else {
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
if err == nil {
coverTempPath = tmpFile.Name()
tmpFile.Write(coverData)
tmpFile.Close()
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
}
}
}
// Only cleanup cover temp for FLAC (native embed).
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
cleanupCover := true
defer func() {
if cleanupCover && coverTempPath != "" {
os.Remove(coverTempPath)
}
}()
// Fetch lyrics
var lyricsLRC string
if req.EmbedLyrics {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
if err != nil {
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
} else if !lyrics.Instrumental {
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
GoLog("[ReEnrich] Track is instrumental\n")
}
}
lower := strings.ToLower(req.FilePath)
isFlac := strings.HasSuffix(lower, ".flac")
// Build enriched metadata response for Dart (includes online search results)
enrichedMeta := map[string]interface{}{
"track_name": req.TrackName,
"artist_name": req.ArtistName,
"album_name": req.AlbumName,
"album_artist": req.AlbumArtist,
"release_date": req.ReleaseDate,
"track_number": req.TrackNumber,
"disc_number": req.DiscNumber,
"isrc": req.ISRC,
"genre": req.Genre,
"label": req.Label,
"copyright": req.Copyright,
"cover_url": req.CoverURL,
"spotify_id": req.SpotifyID,
"duration_ms": req.DurationMs,
}
if isFlac {
// Native Go FLAC metadata embedding
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Lyrics: lyricsLRC,
}
if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
result := map[string]interface{}{
"method": "native",
"success": true,
"enriched_metadata": enrichedMeta,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// MP3/Opus: return metadata map for Dart to use FFmpeg
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
result := map[string]interface{}{
"method": "ffmpeg",
"cover_path": coverTempPath,
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": map[string]string{
"TITLE": req.TrackName,
"ARTIST": req.ArtistName,
"ALBUM": req.AlbumName,
"ALBUMARTIST": req.AlbumArtist,
"DATE": req.ReleaseDate,
"ISRC": req.ISRC,
"GENRE": req.Genre,
},
}
if req.TrackNumber > 0 {
result["metadata"].(map[string]string)["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
}
if req.DiscNumber > 0 {
result["metadata"].(map[string]string)["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
}
if req.Label != "" {
result["metadata"].(map[string]string)["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
result["metadata"].(map[string]string)["COPYRIGHT"] = req.Copyright
}
if lyricsLRC != "" {
result["metadata"].(map[string]string)["LYRICS"] = lyricsLRC
result["metadata"].(map[string]string)["UNSYNCEDLYRICS"] = lyricsLRC
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// ==================== EXTENSION SYSTEM ==================== // ==================== EXTENSION SYSTEM ====================
func InitExtensionSystem(extensionsDir, dataDir string) error { func InitExtensionSystem(extensionsDir, dataDir string) error {
-3
View File
@@ -6,11 +6,8 @@ toolchain go1.25.7
require ( require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacpicture/v2 v2.0.2 github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/flacvorbis/v2 v2.0.2 github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac v1.0.0
github.com/go-flac/go-flac/v2 v2.0.4 github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2 github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
+11 -8
View File
@@ -2,18 +2,17 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
@@ -23,12 +22,14 @@ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
@@ -45,3 +46,5 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+31
View File
@@ -55,6 +55,27 @@ var sharedTransport = &http.Transport{
DisableCompression: true, DisableCompression: true,
} }
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
// Isolated from download traffic so that download failures cannot poison
// the connection pool used by metadata enrichment.
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
}
var sharedClient = &http.Client{ var sharedClient = &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
Timeout: DefaultTimeout, Timeout: DefaultTimeout,
@@ -72,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
} }
} }
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: metadataTransport,
Timeout: timeout,
}
}
func GetSharedClient() *http.Client { func GetSharedClient() *http.Client {
return sharedClient return sharedClient
} }
@@ -82,6 +112,7 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() { func CloseIdleConnections() {
sharedTransport.CloseIdleConnections() sharedTransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
} }
// Also checks for ISP blocking on errors // Also checks for ISP blocking on errors
+27 -3
View File
@@ -9,9 +9,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis" "github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac/v2"
) )
type Metadata struct { type Metadata struct {
@@ -29,6 +29,8 @@ type Metadata struct {
Genre string Genre string
Label string Label string
Copyright string Copyright string
Composer string
Comment string
} }
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
@@ -98,6 +100,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "COPYRIGHT", metadata.Copyright) setComment(cmt, "COPYRIGHT", metadata.Copyright)
} }
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -206,6 +216,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "COPYRIGHT", metadata.Copyright) setComment(cmt, "COPYRIGHT", metadata.Copyright)
} }
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -292,6 +310,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
metadata.Date = getComment(cmt, "YEAR") metadata.Date = getComment(cmt, "YEAR")
} }
metadata.Genre = getComment(cmt, "GENRE")
metadata.Label = getComment(cmt, "ORGANIZATION")
metadata.Copyright = getComment(cmt, "COPYRIGHT")
metadata.Composer = getComment(cmt, "COMPOSER")
metadata.Comment = getComment(cmt, "COMMENT")
break break
} }
} }
+144 -13
View File
@@ -15,18 +15,21 @@ type SongLinkClient struct {
} }
type TrackAvailability struct { type TrackAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"` Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"` Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"` Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"` YouTube bool `json:"youtube"`
AmazonURL string `json:"amazon_url,omitempty"` TidalURL string `json:"tidal_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"` DeezerURL string `json:"deezer_url,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"` YouTubeURL string `json:"youtube_url,omitempty"`
TidalID string `json:"tidal_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
YouTubeID string `json:"youtube_id,omitempty"`
} }
var ( var (
@@ -37,7 +40,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
@@ -119,6 +122,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
} }
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
// Fallback to regular youtube if youtubeMusic not available
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -246,6 +265,52 @@ func extractTidalIDFromURL(tidalURL string) string {
return "" return ""
} }
// extractYouTubeIDFromURL extracts YouTube video ID from URL
// URL formats:
// - https://www.youtube.com/watch?v=VIDEO_ID
// - https://youtu.be/VIDEO_ID
// - https://music.youtube.com/watch?v=VIDEO_ID
func extractYouTubeIDFromURL(youtubeURL string) string {
if youtubeURL == "" {
return ""
}
// Handle youtu.be short URLs
if strings.Contains(youtubeURL, "youtu.be/") {
parts := strings.Split(youtubeURL, "youtu.be/")
if len(parts) >= 2 {
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
if idx := strings.Index(idPart, "&"); idx > 0 {
idPart = idPart[:idx]
}
return strings.TrimSpace(idPart)
}
}
// Handle youtube.com URLs with ?v= parameter
parsed, err := url.Parse(youtubeURL)
if err != nil {
return ""
}
if v := parsed.Query().Get("v"); v != "" {
return v
}
// Handle /embed/ format
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0]
}
}
return ""
}
// isNumeric is defined in library_scan.go // isNumeric is defined in library_scan.go
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
@@ -261,6 +326,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
return availability.DeezerID, nil return availability.DeezerID, nil
} }
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
// AlbumAvailability represents album availability on different platforms // AlbumAvailability represents album availability on different platforms
type AlbumAvailability struct { type AlbumAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
@@ -441,6 +520,19 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -528,6 +620,19 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -584,6 +689,20 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
return availability.AmazonURL, nil return availability.AmazonURL, nil
} }
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
@@ -652,6 +771,18 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
+1 -1
View File
@@ -114,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano()) src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{ c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), httpClient: NewMetadataHTTPClient(15 * time.Second),
clientID: clientID, clientID: clientID,
clientSecret: clientSecret, clientSecret: clientSecret,
rng: rand.New(src), rng: rand.New(src),
+566
View File
@@ -0,0 +1,566 @@
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
package gobackend
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type YouTubeDownloader struct {
client *http.Client
apiURL string
mu sync.Mutex
}
var (
globalYouTubeDownloader *YouTubeDownloader
youtubeDownloaderOnce sync.Once
)
type YouTubeQuality string
const (
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
)
type CobaltRequest struct {
URL string `json:"url"`
AudioBitrate string `json:"audioBitrate,omitempty"`
AudioFormat string `json:"audioFormat,omitempty"`
DownloadMode string `json:"downloadMode,omitempty"`
FilenameStyle string `json:"filenameStyle,omitempty"`
DisableMetadata bool `json:"disableMetadata,omitempty"`
}
type CobaltResponse struct {
Status string `json:"status"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
Error *struct {
Code string `json:"code"`
Context *struct {
Service string `json:"service,omitempty"`
Limit int `json:"limit,omitempty"`
} `json:"context,omitempty"`
} `json:"error,omitempty"`
}
type YouTubeDownloadResult struct {
FilePath string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
Format string // "opus" or "mp3"
Bitrate int
LyricsLRC string
CoverData []byte
}
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
apiURL: "https://api.qwkuns.me",
}
})
return globalYouTubeDownloader
}
// SearchYouTube returns a YouTube Music search URL for the given track
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
searchQuery := url.QueryEscape(query)
GoLog("[YouTube] Search query: %s\n", query)
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
return youtubeMusicURL, nil
}
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
y.mu.Lock()
defer y.mu.Unlock()
var audioFormat string
var audioBitrate string
switch quality {
case YouTubeQualityOpus256:
audioFormat = "opus"
audioBitrate = "256"
case YouTubeQualityMP3320:
audioFormat = "mp3"
audioBitrate = "320"
default:
audioFormat = "mp3"
audioBitrate = "320"
}
// Try SpotubeDL first (primary)
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
if extractErr == nil {
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
videoID, audioFormat, audioBitrate)
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
if err == nil {
return resp, nil
}
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
} else {
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
}
// Fallback: direct Cobalt API (api.qwkuns.me)
cobaltURL := toYouTubeMusicURL(youtubeURL)
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
cobaltURL, audioFormat, audioBitrate)
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
if err != nil {
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
}
return resp, nil
}
// requestCobaltDirect sends a download request to the primary Cobalt API.
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{
URL: videoURL,
AudioFormat: audioFormat,
AudioBitrate: audioBitrate,
DownloadMode: "audio",
FilenameStyle: "basic",
DisableMetadata: true,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("cobalt API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
}
var cobaltResp CobaltResponse
if err := json.Unmarshal(body, &cobaltResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
}
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
}
if cobaltResp.URL == "" {
return nil, fmt.Errorf("no download URL in response")
}
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
return &cobaltResp, nil
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
videoID, audioFormat, audioBitrate)
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("spotubedl request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
}
if result.URL == "" {
return nil, fmt.Errorf("no download URL from spotubedl")
}
GoLog("[YouTube] Got download URL from SpotubeDL\n")
return &CobaltResponse{
Status: "tunnel",
URL: result.URL,
}, nil
}
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[YouTube] Download completed: %d bytes written\n", written)
return nil
}
func BuildYouTubeSearchURL(trackName, artistName string) string {
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
}
func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
func isYouTubeVideoID(s string) bool {
if len(s) != 11 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
func IsYouTubeURL(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "youtube.com") ||
strings.Contains(lower, "youtu.be") ||
strings.Contains(lower, "music.youtube.com")
}
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
func toYouTubeMusicURL(rawURL string) string {
videoID, err := ExtractYouTubeVideoID(rawURL)
if err != nil {
return rawURL
}
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func ExtractYouTubeVideoID(urlStr string) (string, error) {
if strings.Contains(urlStr, "youtu.be/") {
parts := strings.Split(urlStr, "youtu.be/")
if len(parts) >= 2 {
videoID := strings.Split(parts[1], "?")[0]
videoID = strings.Split(videoID, "&")[0]
return strings.TrimSpace(videoID), nil
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
// /watch?v=
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
// /embed/
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
// /v/
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
return "", fmt.Errorf("could not extract video ID from URL")
}
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader()
var quality YouTubeQuality
switch strings.ToLower(req.Quality) {
case "opus_256", "opus256", "opus":
quality = YouTubeQualityOpus256
case "mp3_320", "mp3320", "mp3":
quality = YouTubeQualityMP3320
default:
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
}
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
var youtubeURL string
var lookupErr error
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
}
// Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
}
}
// Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
}
}
// Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
youtubeURL = availability.YouTubeURL
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
} else if isrcErr != nil {
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
}
}
// Cobalt requires direct video URLs, not search URLs
if youtubeURL == "" {
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
}
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
if err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
var ext string
var format string
var bitrate int
switch quality {
case YouTubeQualityOpus256:
ext = ".opus"
format = "opus"
bitrate = 256
case YouTubeQualityMP3320:
ext = ".mp3"
format = "mp3"
bitrate = 320
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ext
var outputPath string
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
outputPath = req.OutputDir + "/" + filename
}
GoLog("[YouTube] Downloading to: %s\n", outputPath)
// Parallel fetch cover art + lyrics
var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
lyricsLRC := ""
var coverData []byte
if parallelResult != nil {
if parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
}
if parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
}
}
return YouTubeDownloadResult{
FilePath: outputPath,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Format: format,
Bitrate: bitrate,
LyricsLRC: lyricsLRC,
CoverData: coverData,
}, nil
}
+8
View File
@@ -217,6 +217,14 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "editFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let metadataJson = args["metadata_json"] as? String ?? "{}"
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
if let error = error { throw error }
return response
case "searchDeezerAll": case "searchDeezerAll":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.5.1'; static const String version = '3.6.0';
static const String buildNumber = '75'; static const String buildNumber = '77';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+378
View File
@@ -712,6 +712,12 @@ abstract class AppLocalizations {
/// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'** /// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'**
String get optionsSpotifyWarning; String get optionsSpotifyWarning;
/// Warning about Spotify API deprecation
///
/// In en, this message translates to:
/// **'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'**
String get optionsSpotifyDeprecationWarning;
/// Extensions page title /// Extensions page title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -988,6 +994,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:
@@ -3484,6 +3502,12 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'** /// **'Actual quality depends on track availability from the service'**
String get qualityNote; String get qualityNote;
/// Note for YouTube service explaining lossy-only quality
///
/// In en, this message translates to:
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Setting - show quality picker /// Setting - show quality picker
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3508,6 +3532,24 @@ abstract class AppLocalizations {
/// **'Album Folder Structure'** /// **'Album Folder Structure'**
String get downloadAlbumFolderStructure; String get downloadAlbumFolderStructure;
/// Setting - choose whether artist folders use Album Artist or Track Artist
///
/// In en, this message translates to:
/// **'Use Album Artist for folders'**
String get downloadUseAlbumArtistForFolders;
/// Subtitle when Album Artist is used for folder naming
///
/// In en, this message translates to:
/// **'Artist folders use Album Artist when available'**
String get downloadUseAlbumArtistForFoldersAlbumSubtitle;
/// Subtitle when Track Artist is used for folder naming
///
/// In en, this message translates to:
/// **'Artist folders use Track Artist only'**
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
/// Setting - output file format /// Setting - output file format
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3922,6 +3964,18 @@ abstract class AppLocalizations {
/// **'Playlist'** /// **'Playlist'**
String get recentTypePlaylist; String get recentTypePlaylist;
/// Empty state text for recent access list
///
/// In en, this message translates to:
/// **'No recent items yet'**
String get recentEmpty;
/// Button label to unhide hidden downloads in recent access
///
/// In en, this message translates to:
/// **'Show All Downloads'**
String get recentShowAllDownloads;
/// Snackbar message when tapping playlist in recent access /// Snackbar message when tapping playlist in recent access
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4090,6 +4144,18 @@ abstract class AppLocalizations {
/// **'Scan music & detect duplicates'** /// **'Scan music & detect duplicates'**
String get settingsLocalLibrarySubtitle; String get settingsLocalLibrarySubtitle;
/// Settings menu item - cache management
///
/// In en, this message translates to:
/// **'Storage & Cache'**
String get settingsCache;
/// Subtitle for cache management menu
///
/// In en, this message translates to:
/// **'View size and clear cached data'**
String get settingsCacheSubtitle;
/// Library settings page title /// Library settings page title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4396,6 +4462,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:
@@ -4755,6 +4839,300 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'No orphaned entries found'** /// **'No orphaned entries found'**
String get cleanupOrphanedDownloadsNone; String get cleanupOrphanedDownloadsNone;
/// Cache management page title
///
/// In en, this message translates to:
/// **'Storage & Cache'**
String get cacheTitle;
/// Heading for cache summary card
///
/// In en, this message translates to:
/// **'Cache overview'**
String get cacheSummaryTitle;
/// Helper text for cache summary card
///
/// In en, this message translates to:
/// **'Clearing cache will not remove downloaded music files.'**
String get cacheSummarySubtitle;
/// Total cache size shown in summary
///
/// In en, this message translates to:
/// **'Estimated cache usage: {size}'**
String cacheEstimatedTotal(String size);
/// Section header for cache entries
///
/// In en, this message translates to:
/// **'Cached Data'**
String get cacheSectionStorage;
/// Section header for cleanup actions
///
/// In en, this message translates to:
/// **'Maintenance'**
String get cacheSectionMaintenance;
/// Cache item title for app cache directory
///
/// In en, this message translates to:
/// **'App cache directory'**
String get cacheAppDirectory;
/// Description of what app cache directory contains
///
/// In en, this message translates to:
/// **'HTTP responses, WebView data, and other temporary app data.'**
String get cacheAppDirectoryDesc;
/// Cache item title for temporary files directory
///
/// In en, this message translates to:
/// **'Temporary directory'**
String get cacheTempDirectory;
/// Description of what temporary directory contains
///
/// In en, this message translates to:
/// **'Temporary files from downloads and audio conversion.'**
String get cacheTempDirectoryDesc;
/// Cache item title for persistent cover images
///
/// In en, this message translates to:
/// **'Cover image cache'**
String get cacheCoverImage;
/// Description of what cover image cache contains
///
/// In en, this message translates to:
/// **'Downloaded album and track cover art. Will re-download when viewed.'**
String get cacheCoverImageDesc;
/// Cache item title for local library cover art images
///
/// In en, this message translates to:
/// **'Library cover cache'**
String get cacheLibraryCover;
/// Description of what library cover cache contains
///
/// In en, this message translates to:
/// **'Cover art extracted from local music files. Will re-extract on next scan.'**
String get cacheLibraryCoverDesc;
/// Cache item title for explore home feed cache
///
/// In en, this message translates to:
/// **'Explore feed cache'**
String get cacheExploreFeed;
/// Description of what explore feed cache contains
///
/// In en, this message translates to:
/// **'Explore tab content (new releases, trending). Will refresh on next visit.'**
String get cacheExploreFeedDesc;
/// Cache item title for track ID lookup cache
///
/// In en, this message translates to:
/// **'Track lookup cache'**
String get cacheTrackLookup;
/// Description of what track lookup cache contains
///
/// In en, this message translates to:
/// **'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'**
String get cacheTrackLookupDesc;
/// Description of what cleanup unused data does
///
/// In en, this message translates to:
/// **'Remove orphaned download history and library entries for missing files.'**
String get cacheCleanupUnusedDesc;
/// Label when cache category has no data
///
/// In en, this message translates to:
/// **'No cached data'**
String get cacheNoData;
/// Cache size and file count
///
/// In en, this message translates to:
/// **'{size} in {count} files'**
String cacheSizeWithFiles(String size, int count);
/// Cache size only
///
/// In en, this message translates to:
/// **'{size}'**
String cacheSizeOnly(String size);
/// Track cache entry count
///
/// In en, this message translates to:
/// **'{count} entries'**
String cacheEntries(int count);
/// Snackbar after clearing selected cache
///
/// In en, this message translates to:
/// **'Cleared: {target}'**
String cacheClearSuccess(String target);
/// Dialog title before clearing one cache category
///
/// In en, this message translates to:
/// **'Clear cache?'**
String get cacheClearConfirmTitle;
/// Dialog message before clearing selected cache
///
/// In en, this message translates to:
/// **'This will clear cached data for {target}. Downloaded music files will not be deleted.'**
String cacheClearConfirmMessage(String target);
/// Dialog title before clearing all caches
///
/// In en, this message translates to:
/// **'Clear all cache?'**
String get cacheClearAllConfirmTitle;
/// Dialog message before clearing all caches
///
/// In en, this message translates to:
/// **'This will clear all cache categories on this page. Downloaded music files will not be deleted.'**
String get cacheClearAllConfirmMessage;
/// Button label to clear all caches
///
/// In en, this message translates to:
/// **'Clear all cache'**
String get cacheClearAll;
/// Action title for cleaning unused entries
///
/// In en, this message translates to:
/// **'Cleanup unused data'**
String get cacheCleanupUnused;
/// Subtitle for cleanup unused data action
///
/// In en, this message translates to:
/// **'Remove orphaned download history and missing library entries'**
String get cacheCleanupUnusedSubtitle;
/// Snackbar after unused data cleanup
///
/// In en, this message translates to:
/// **'Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries'**
String cacheCleanupResult(int downloadCount, int libraryCount);
/// Button label to refresh cache statistics
///
/// In en, this message translates to:
/// **'Refresh stats'**
String get cacheRefreshStats;
/// Menu action - save album cover art as file
///
/// In en, this message translates to:
/// **'Save Cover Art'**
String get trackSaveCoverArt;
/// Subtitle for save cover art action
///
/// In en, this message translates to:
/// **'Save album art as .jpg file'**
String get trackSaveCoverArtSubtitle;
/// Menu action - save lyrics as .lrc file
///
/// In en, this message translates to:
/// **'Save Lyrics (.lrc)'**
String get trackSaveLyrics;
/// Subtitle for save lyrics action
///
/// In en, this message translates to:
/// **'Fetch and save lyrics as .lrc file'**
String get trackSaveLyricsSubtitle;
/// Menu action - re-embed metadata into audio file
///
/// In en, this message translates to:
/// **'Re-enrich Metadata'**
String get trackReEnrich;
/// Subtitle for re-enrich metadata action
///
/// In en, this message translates to:
/// **'Re-embed metadata without re-downloading'**
String get trackReEnrichSubtitle;
/// Subtitle for re-enrich metadata action for local items
///
/// In en, this message translates to:
/// **'Search metadata online and embed into file'**
String get trackReEnrichOnlineSubtitle;
/// Menu action - edit embedded metadata
///
/// In en, this message translates to:
/// **'Edit Metadata'**
String get trackEditMetadata;
/// Snackbar after cover art saved
///
/// In en, this message translates to:
/// **'Cover art saved to {fileName}'**
String trackCoverSaved(String fileName);
/// Snackbar when no cover art URL or embedded cover
///
/// In en, this message translates to:
/// **'No cover art source available'**
String get trackCoverNoSource;
/// Snackbar after lyrics saved
///
/// In en, this message translates to:
/// **'Lyrics saved to {fileName}'**
String trackLyricsSaved(String fileName);
/// Snackbar while re-enriching metadata
///
/// In en, this message translates to:
/// **'Re-enriching metadata...'**
String get trackReEnrichProgress;
/// Snackbar while searching metadata from internet for local items
///
/// In en, this message translates to:
/// **'Searching metadata online...'**
String get trackReEnrichSearching;
/// Snackbar after successful re-enrichment
///
/// In en, this message translates to:
/// **'Metadata re-enriched successfully'**
String get trackReEnrichSuccess;
/// Snackbar when FFmpeg embed fails for MP3/Opus
///
/// In en, this message translates to:
/// **'FFmpeg metadata embed failed'**
String get trackReEnrichFfmpegFailed;
/// Snackbar when save operation fails
///
/// In en, this message translates to:
/// **'Failed: {error}'**
String trackSaveFailed(String error);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+226
View File
@@ -352,6 +352,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com'; 'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Erweiterungen'; String get extensionsTitle => 'Erweiterungen';
@@ -504,6 +508,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.';
@@ -1922,6 +1933,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1934,6 +1949,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2169,6 +2195,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2275,6 +2307,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2442,6 +2480,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';
@@ -2678,4 +2725,183 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,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.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,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';
@@ -2663,4 +2710,183 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,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.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,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';
@@ -2663,6 +2710,185 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,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.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,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';
@@ -2663,4 +2710,183 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,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.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,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';
@@ -2663,4 +2710,183 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+230
View File
@@ -347,6 +347,10 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com'; 'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
@override @override
String get extensionsTitle => 'Ekstensi'; String get extensionsTitle => 'Ekstensi';
@@ -496,6 +500,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.';
@@ -1919,6 +1930,10 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1931,6 +1946,18 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Struktur Folder Album'; String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get downloadUseAlbumArtistForFolders =>
'Gunakan Album Artist untuk folder';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Folder artis memakai Album Artist jika tersedia';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Folder artis hanya memakai Track Artist';
@override @override
String get downloadSaveFormat => 'Simpan Format'; String get downloadSaveFormat => 'Simpan Format';
@@ -2167,6 +2194,12 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'Belum ada item terbaru';
@override
String get recentShowAllDownloads => 'Tampilkan Semua Download';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2273,6 +2306,12 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Penyimpanan & Cache';
@override
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2440,6 +2479,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';
@@ -2678,4 +2726,186 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => String get cleanupOrphanedDownloadsNone =>
'Tidak ada entri unduhan tidak valid'; 'Tidak ada entri unduhan tidak valid';
@override
String get cacheTitle => 'Penyimpanan & Cache';
@override
String get cacheSummaryTitle => 'Ringkasan cache';
@override
String get cacheSummarySubtitle =>
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimasi penggunaan cache: $size';
}
@override
String get cacheSectionStorage => 'Data Cache';
@override
String get cacheSectionMaintenance => 'Perawatan';
@override
String get cacheAppDirectory => 'Direktori cache aplikasi';
@override
String get cacheAppDirectoryDesc =>
'Respons HTTP, data WebView, dan data sementara aplikasi.';
@override
String get cacheTempDirectory => 'Direktori sementara';
@override
String get cacheTempDirectoryDesc =>
'File sementara dari proses download dan konversi audio.';
@override
String get cacheCoverImage => 'Cache gambar cover';
@override
String get cacheCoverImageDesc =>
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
@override
String get cacheLibraryCover => 'Cache cover library';
@override
String get cacheLibraryCoverDesc =>
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
@override
String get cacheExploreFeed => 'Cache feed Explore';
@override
String get cacheExploreFeedDesc =>
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
@override
String get cacheTrackLookup => 'Cache pencocokan lagu';
@override
String get cacheTrackLookupDesc =>
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
@override
String get cacheCleanupUnusedDesc =>
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
@override
String get cacheNoData => 'Tidak ada data cache';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size dalam $count file';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entri';
}
@override
String cacheClearSuccess(String target) {
return 'Berhasil dibersihkan: $target';
}
@override
String get cacheClearConfirmTitle => 'Bersihkan cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
}
@override
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
@override
String get cacheClearAllConfirmMessage =>
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
@override
String get cacheClearAll => 'Bersihkan semua cache';
@override
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
@override
String get cacheCleanupUnusedSubtitle =>
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
}
@override
String get cacheRefreshStats => 'Segarkan statistik';
@override
String get trackSaveCoverArt => 'Simpan Cover Art';
@override
String get trackSaveCoverArtSubtitle =>
'Simpan cover album sebagai file .jpg';
@override
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
@override
String get trackSaveLyricsSubtitle =>
'Ambil dan simpan lirik sebagai file .lrc';
@override
String get trackReEnrich => 'Perkaya Ulang Metadata';
@override
String get trackReEnrichSubtitle =>
'Tanamkan ulang metadata tanpa mengunduh ulang';
@override
String get trackReEnrichOnlineSubtitle =>
'Cari metadata dari internet dan tanamkan ke file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art disimpan ke $fileName';
}
@override
String get trackCoverNoSource => 'Tidak ada sumber cover art';
@override
String trackLyricsSaved(String fileName) {
return 'Lirik disimpan ke $fileName';
}
@override
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
@override
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
@override
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
@override
String get trackReEnrichFfmpegFailed =>
'Gagal menanamkan metadata via FFmpeg';
@override
String trackSaveFailed(String error) {
return 'Gagal: $error';
}
} }
+226
View File
@@ -340,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。'; 'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => '拡張'; String get extensionsTitle => '拡張';
@@ -487,6 +491,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 のトラックをロスレス品質でダウンロードします。';
@@ -1895,6 +1906,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1907,6 +1922,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造'; String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => '形式を保存'; String get downloadSaveFormat => '形式を保存';
@@ -2140,6 +2166,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'プレイリスト'; String get recentTypePlaylist => 'プレイリスト';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'プレイリスト: $name'; return 'プレイリスト: $name';
@@ -2246,6 +2278,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2413,6 +2451,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';
@@ -2649,4 +2696,183 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,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.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,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';
@@ -2663,4 +2710,183 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,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.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,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';
@@ -2663,4 +2710,183 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,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.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,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';
@@ -2663,6 +2710,185 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).
+226
View File
@@ -354,6 +354,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com'; 'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Расширения'; String get extensionsTitle => 'Расширения';
@@ -504,6 +508,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.';
@@ -1945,6 +1956,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе'; 'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1957,6 +1972,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Структура папок альбома'; String get downloadAlbumFolderStructure => 'Структура папок альбома';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Формат сохранения'; String get downloadSaveFormat => 'Формат сохранения';
@@ -2199,6 +2225,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Плейлист'; String get recentTypePlaylist => 'Плейлист';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Плейлист: $name'; return 'Плейлист: $name';
@@ -2306,6 +2338,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2473,6 +2511,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';
@@ -2709,4 +2756,183 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -348,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin'; 'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Eklentiler'; String get extensionsTitle => 'Eklentiler';
@@ -498,6 +502,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.';
@@ -1922,6 +1933,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1934,6 +1949,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2169,6 +2195,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2275,6 +2307,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2442,6 +2480,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';
@@ -2678,4 +2725,183 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,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.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,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';
@@ -2663,6 +2710,185 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Chinese, as used in China (`zh_CN`). /// The translations for Chinese, as used in China (`zh_CN`).
+183 -1
View File
@@ -241,6 +241,8 @@
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"}, "@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"}, "@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
"@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"},
"extensionsTitle": "Extensions", "extensionsTitle": "Extensions",
"@extensionsTitle": {"description": "Extensions page title"}, "@extensionsTitle": {"description": "Extensions page title"},
@@ -346,6 +348,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"},
@@ -1408,6 +1414,8 @@
"@lossyFormatOpusSubtitle": {"description": "Opus format description"}, "@lossyFormatOpusSubtitle": {"description": "Opus format description"},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"}, "@qualityNote": {"description": "Note about quality availability"},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"}, "@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
@@ -1417,6 +1425,12 @@
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"}, "@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
"downloadAlbumFolderStructure": "Album Folder Structure", "downloadAlbumFolderStructure": "Album Folder Structure",
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"}, "@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
"downloadSaveFormat": "Save Format", "downloadSaveFormat": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"}, "@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service", "downloadSelectService": "Select Service",
@@ -1590,6 +1604,12 @@
"@recentTypeSong": {"description": "Recent access item type - song/track"}, "@recentTypeSong": {"description": "Recent access item type - song/track"},
"recentTypePlaylist": "Playlist", "recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {"description": "Recent access item type - playlist"}, "@recentTypePlaylist": {"description": "Recent access item type - playlist"},
"recentEmpty": "No recent items yet",
"@recentEmpty": {"description": "Empty state text for recent access list"},
"recentShowAllDownloads": "Show All Downloads",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}", "recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": { "@recentPlaylistInfo": {
@@ -1700,6 +1720,10 @@
"@settingsLocalLibrary": {"description": "Settings menu item - local library"}, "@settingsLocalLibrary": {"description": "Settings menu item - local library"},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates", "settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"}, "@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
"settingsCache": "Storage & Cache",
"@settingsCache": {"description": "Settings menu item - cache management"},
"settingsCacheSubtitle": "View size and clear cached data",
"@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"},
"libraryTitle": "Local Library", "libraryTitle": "Local Library",
"@libraryTitle": {"description": "Library settings page title"}, "@libraryTitle": {"description": "Library settings page title"},
"libraryStatus": "Library Status", "libraryStatus": "Library Status",
@@ -1824,6 +1848,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",
@@ -2000,5 +2030,157 @@
} }
}, },
"cleanupOrphanedDownloadsNone": "No orphaned entries found", "cleanupOrphanedDownloadsNone": "No orphaned entries found",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"} "@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
"cacheTitle": "Storage & Cache",
"@cacheTitle": {"description": "Cache management page title"},
"cacheSummaryTitle": "Cache overview",
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
"cacheEstimatedTotal": "Estimated cache usage: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheSectionStorage": "Cached Data",
"@cacheSectionStorage": {"description": "Section header for cache entries"},
"cacheSectionMaintenance": "Maintenance",
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
"cacheAppDirectory": "App cache directory",
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
"cacheTempDirectory": "Temporary directory",
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
"cacheCoverImage": "Cover image cache",
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
"cacheLibraryCover": "Library cover cache",
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
"cacheExploreFeed": "Explore feed cache",
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
"cacheTrackLookup": "Track lookup cache",
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
"cacheNoData": "No cached data",
"@cacheNoData": {"description": "Label when cache category has no data"},
"cacheSizeWithFiles": "{size} in {count} files",
"@cacheSizeWithFiles": {
"description": "Cache size and file count",
"placeholders": {
"size": {"type": "String"},
"count": {"type": "int"}
}
},
"cacheSizeOnly": "{size}",
"@cacheSizeOnly": {
"description": "Cache size only",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheEntries": "{count} entries",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
"count": {"type": "int"}
}
},
"cacheClearSuccess": "Cleared: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearConfirmTitle": "Clear cache?",
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearAllConfirmTitle": "Clear all cache?",
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
"cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.",
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
"cacheClearAll": "Clear all cache",
"@cacheClearAll": {"description": "Button label to clear all caches"},
"cacheCleanupUnused": "Cleanup unused data",
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
"downloadCount": {"type": "int"},
"libraryCount": {"type": "int"}
}
},
"cacheRefreshStats": "Refresh stats",
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
"trackSaveCoverArt": "Save Cover Art",
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
"trackSaveCoverArtSubtitle": "Save album art as .jpg file",
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
"trackSaveLyrics": "Save Lyrics (.lrc)",
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
"trackReEnrich": "Re-enrich Metadata",
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
"trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
"trackCoverSaved": "Cover art saved to {fileName}",
"@trackCoverSaved": {
"description": "Snackbar after cover art saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackCoverNoSource": "No cover art source available",
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
"trackLyricsSaved": "Lyrics saved to {fileName}",
"@trackLyricsSaved": {
"description": "Snackbar after lyrics saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackReEnrichProgress": "Re-enriching metadata...",
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
"trackReEnrichSearching": "Searching metadata online...",
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
"trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed",
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"placeholders": {
"error": {"type": "String"}
}
}
} }
+185 -1
View File
@@ -151,6 +151,14 @@
"@settingsExtensions": { "@settingsExtensions": {
"description": "Settings section - extension management" "description": "Settings section - extension management"
}, },
"settingsCache": "Penyimpanan & Cache",
"@settingsCache": {
"description": "Settings menu item - cache management"
},
"settingsCacheSubtitle": "Lihat ukuran dan bersihkan data cache",
"@settingsCacheSubtitle": {
"description": "Subtitle for cache management menu"
},
"settingsAbout": "Tentang", "settingsAbout": "Tentang",
"@settingsAbout": { "@settingsAbout": {
"description": "Settings section - app info" "description": "Settings section - app info"
@@ -426,6 +434,10 @@
"@optionsSpotifyWarning": { "@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement" "description": "Info about Spotify API requirement"
}, },
"optionsSpotifyDeprecationWarning": "Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.",
"@optionsSpotifyDeprecationWarning": {
"description": "Warning about Spotify API deprecation"
},
"extensionsTitle": "Ekstensi", "extensionsTitle": "Ekstensi",
"@extensionsTitle": { "@extensionsTitle": {
"description": "Extensions page title" "description": "Extensions page title"
@@ -2465,6 +2477,18 @@
"@downloadAlbumFolderStructure": { "@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization" "description": "Setting - album folder organization"
}, },
"downloadUseAlbumArtistForFolders": "Gunakan Album Artist untuk folder",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Folder artis memakai Album Artist jika tersedia",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
"description": "Subtitle when Album Artist is used for folder naming"
},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Folder artis hanya memakai Track Artist",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
},
"downloadSaveFormat": "Simpan Format", "downloadSaveFormat": "Simpan Format",
"@downloadSaveFormat": { "@downloadSaveFormat": {
"description": "Setting - output file format" "description": "Setting - output file format"
@@ -2727,6 +2751,14 @@
"@recentTypePlaylist": { "@recentTypePlaylist": {
"description": "Recent access item type - playlist" "description": "Recent access item type - playlist"
}, },
"recentEmpty": "Belum ada item terbaru",
"@recentEmpty": {
"description": "Empty state text for recent access list"
},
"recentShowAllDownloads": "Tampilkan Semua Download",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}", "recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": { "@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access", "description": "Snackbar message when tapping playlist in recent access",
@@ -3018,5 +3050,157 @@
} }
}, },
"cleanupOrphanedDownloadsNone": "Tidak ada entri unduhan tidak valid", "cleanupOrphanedDownloadsNone": "Tidak ada entri unduhan tidak valid",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"} "@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
"cacheTitle": "Penyimpanan & Cache",
"@cacheTitle": {"description": "Cache management page title"},
"cacheSummaryTitle": "Ringkasan cache",
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
"cacheSummarySubtitle": "Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.",
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
"cacheEstimatedTotal": "Estimasi penggunaan cache: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheSectionStorage": "Data Cache",
"@cacheSectionStorage": {"description": "Section header for cache entries"},
"cacheSectionMaintenance": "Perawatan",
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
"cacheAppDirectory": "Direktori cache aplikasi",
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
"cacheAppDirectoryDesc": "Respons HTTP, data WebView, dan data sementara aplikasi.",
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
"cacheTempDirectory": "Direktori sementara",
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
"cacheTempDirectoryDesc": "File sementara dari proses download dan konversi audio.",
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
"cacheCoverImage": "Cache gambar cover",
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
"cacheCoverImageDesc": "Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.",
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
"cacheLibraryCover": "Cache cover library",
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
"cacheLibraryCoverDesc": "Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.",
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
"cacheExploreFeed": "Cache feed Explore",
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
"cacheExploreFeedDesc": "Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.",
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
"cacheTrackLookup": "Cache pencocokan lagu",
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
"cacheTrackLookupDesc": "Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.",
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
"cacheCleanupUnusedDesc": "Hapus entri riwayat download dan library yang filenya sudah tidak ada.",
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
"cacheNoData": "Tidak ada data cache",
"@cacheNoData": {"description": "Label when cache category has no data"},
"cacheSizeWithFiles": "{size} dalam {count} file",
"@cacheSizeWithFiles": {
"description": "Cache size and file count",
"placeholders": {
"size": {"type": "String"},
"count": {"type": "int"}
}
},
"cacheSizeOnly": "{size}",
"@cacheSizeOnly": {
"description": "Cache size only",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheEntries": "{count} entri",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
"count": {"type": "int"}
}
},
"cacheClearSuccess": "Berhasil dibersihkan: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearConfirmTitle": "Bersihkan cache?",
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
"cacheClearConfirmMessage": "Ini akan membersihkan data cache untuk {target}. File musik yang sudah diunduh tidak akan dihapus.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearAllConfirmTitle": "Bersihkan semua cache?",
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
"cacheClearAllConfirmMessage": "Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.",
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
"cacheClearAll": "Bersihkan semua cache",
"@cacheClearAll": {"description": "Button label to clear all caches"},
"cacheCleanupUnused": "Bersihkan data tidak terpakai",
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
"cacheCleanupUnusedSubtitle": "Hapus riwayat unduhan yatim dan entri library yang file-nya hilang",
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
"cacheCleanupResult": "Pembersihan selesai: {downloadCount} unduhan yatim, {libraryCount} entri library hilang",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
"downloadCount": {"type": "int"},
"libraryCount": {"type": "int"}
}
},
"cacheRefreshStats": "Segarkan statistik",
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
"trackSaveCoverArt": "Simpan Cover Art",
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
"trackSaveCoverArtSubtitle": "Simpan cover album sebagai file .jpg",
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
"trackSaveLyrics": "Simpan Lirik (.lrc)",
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
"trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc",
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
"trackReEnrich": "Perkaya Ulang Metadata",
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
"trackReEnrichSubtitle": "Tanamkan ulang metadata tanpa mengunduh ulang",
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
"trackReEnrichOnlineSubtitle": "Cari metadata dari internet dan tanamkan ke file",
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
"trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
"trackCoverSaved": "Cover art disimpan ke {fileName}",
"@trackCoverSaved": {
"description": "Snackbar after cover art saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackCoverNoSource": "Tidak ada sumber cover art",
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
"trackLyricsSaved": "Lirik disimpan ke {fileName}",
"@trackLyricsSaved": {
"description": "Snackbar after lyrics saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackReEnrichProgress": "Memperkaya ulang metadata...",
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
"trackReEnrichSearching": "Mencari metadata dari internet...",
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
"trackReEnrichSuccess": "Metadata berhasil diperkaya ulang",
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
"trackReEnrichFfmpegFailed": "Gagal menanamkan metadata via FFmpeg",
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
"trackSaveFailed": "Gagal: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"placeholders": {
"error": {"type": "String"}
}
}
} }
+4
View File
@@ -28,6 +28,7 @@ class DownloadItem {
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double speedMBps; final double speedMBps;
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
final String? filePath; final String? filePath;
final String? error; final String? error;
final DownloadErrorType? errorType; final DownloadErrorType? errorType;
@@ -41,6 +42,7 @@ class DownloadItem {
this.status = DownloadStatus.queued, this.status = DownloadStatus.queued,
this.progress = 0.0, this.progress = 0.0,
this.speedMBps = 0.0, this.speedMBps = 0.0,
this.bytesReceived = 0,
this.filePath, this.filePath,
this.error, this.error,
this.errorType, this.errorType,
@@ -55,6 +57,7 @@ class DownloadItem {
DownloadStatus? status, DownloadStatus? status,
double? progress, double? progress,
double? speedMBps, double? speedMBps,
int? bytesReceived,
String? filePath, String? filePath,
String? error, String? error,
DownloadErrorType? errorType, DownloadErrorType? errorType,
@@ -68,6 +71,7 @@ class DownloadItem {
status: status ?? this.status, status: status ?? this.status,
progress: progress ?? this.progress, progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps, speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived,
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
error: error ?? this.error, error: error ?? this.error,
errorType: errorType ?? this.errorType, errorType: errorType ?? this.errorType,
+2
View File
@@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
DownloadStatus.queued, DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0, progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0, speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?, filePath: json['filePath'] as String?,
error: json['error'] as String?, error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -30,6 +31,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'status': _$DownloadStatusEnumMap[instance.status]!, 'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress, 'progress': instance.progress,
'speedMBps': instance.speedMBps, 'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived,
'filePath': instance.filePath, 'filePath': instance.filePath,
'error': instance.error, 'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+5
View File
@@ -19,6 +19,7 @@ class AppSettings {
final String updateChannel; final String updateChannel;
final bool hasSearchedBefore; final bool hasSearchedBefore;
final String folderOrganization; final String folderOrganization;
final bool useAlbumArtistForFolders;
final String historyViewMode; final String historyViewMode;
final String historyFilterMode; final String historyFilterMode;
final bool askQualityBeforeDownload; final bool askQualityBeforeDownload;
@@ -63,6 +64,7 @@ class AppSettings {
this.updateChannel = 'stable', this.updateChannel = 'stable',
this.hasSearchedBefore = false, this.hasSearchedBefore = false,
this.folderOrganization = 'none', this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.historyViewMode = 'grid', this.historyViewMode = 'grid',
this.historyFilterMode = 'all', this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true, this.askQualityBeforeDownload = true,
@@ -106,6 +108,7 @@ class AppSettings {
String? updateChannel, String? updateChannel,
bool? hasSearchedBefore, bool? hasSearchedBefore,
String? folderOrganization, String? folderOrganization,
bool? useAlbumArtistForFolders,
String? historyViewMode, String? historyViewMode,
String? historyFilterMode, String? historyFilterMode,
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
@@ -149,6 +152,8 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel, updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization, folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
historyViewMode: historyViewMode ?? this.historyViewMode, historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode, historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
+2
View File
@@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable', updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none', folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all', historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -68,6 +69,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'updateChannel': instance.updateChannel, 'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore, 'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization, 'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'historyViewMode': instance.historyViewMode, 'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode, 'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
+270 -14
View File
@@ -600,11 +600,13 @@ class _ProgressUpdate {
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double? speedMBps; final double? speedMBps;
final int? bytesReceived;
const _ProgressUpdate({ const _ProgressUpdate({
required this.status, required this.status,
required this.progress, required this.progress,
this.speedMBps, this.speedMBps,
this.bytesReceived,
}); });
} }
@@ -801,6 +803,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
status: DownloadStatus.downloading, status: DownloadStatus.downloading,
progress: percentage, progress: percentage,
speedMBps: speedMBps, speedMBps: speedMBps,
bytesReceived: bytesReceived,
); );
final mbReceived = bytesReceived / (1024 * 1024); final mbReceived = bytesReceived / (1024 * 1024);
@@ -835,10 +838,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
status: update.status, status: update.status,
progress: update.progress, progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps, speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived,
); );
if (current.status != next.status || if (current.status != next.status ||
current.progress != next.progress || current.progress != next.progress ||
current.speedMBps != next.speedMBps) { current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived) {
if (!changed) { if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems); updatedItems = List<DownloadItem>.from(updatedItems);
changed = true; changed = true;
@@ -1027,14 +1032,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, { String folderOrganization, {
bool separateSingles = false, bool separateSingles = false,
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
}) async { }) async {
String baseDir = state.outputDir; String baseDir = state.outputDir;
final albumArtist = final folderArtist = useAlbumArtistForFolders
_normalizeOptionalString(track.albumArtist) ?? track.artistName; ? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) { if (isSingle) {
@@ -1092,7 +1099,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String subPath = ''; String subPath = '';
switch (folderOrganization) { switch (folderOrganization) {
case 'artist': case 'artist':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
subPath = artistName; subPath = artistName;
break; break;
case 'album': case 'album':
@@ -1100,7 +1107,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
subPath = albumName; subPath = albumName;
break; break;
case 'artist_album': case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
subPath = '$artistName${Platform.pathSeparator}$albumName'; subPath = '$artistName${Platform.pathSeparator}$albumName';
break; break;
@@ -1144,13 +1151,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, { String folderOrganization, {
bool separateSingles = false, bool separateSingles = false,
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
}) async { }) async {
final albumArtist = final folderArtist = useAlbumArtistForFolders
_normalizeOptionalString(track.albumArtist) ?? track.artistName; ? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) { if (isSingle) {
@@ -1186,11 +1195,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
switch (folderOrganization) { switch (folderOrganization) {
case 'artist': case 'artist':
return _sanitizeFolderName(albumArtist); return _sanitizeFolderName(folderArtist);
case 'album': case 'album':
return _sanitizeFolderName(track.albumName); return _sanitizeFolderName(track.albumName);
case 'artist_album': case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
return '$artistName/$albumName'; return '$artistName/$albumName';
default: default:
@@ -1199,6 +1208,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String _determineOutputExt(String quality, String service) { String _determineOutputExt(String quality, String service) {
// YouTube provider - lossy only (Opus or MP3)
if (service.toLowerCase() == 'youtube') {
if (quality.toLowerCase().contains('mp3')) {
return '.mp3';
}
return '.opus';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a'; return '.m4a';
} }
@@ -1214,8 +1230,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case '.opus': case '.opus':
return 'audio/ogg'; return 'audio/ogg';
case '.flac': case '.flac':
default:
return 'audio/flac'; return 'audio/flac';
case '.lrc':
return 'application/octet-stream';
default:
return 'application/octet-stream';
} }
} }
@@ -1234,6 +1253,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return match?.group(1); return match?.group(1);
} }
static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$');
bool _isValidISRC(String value) {
return _isrcRegex.hasMatch(value.toUpperCase());
}
void updateSettings(AppSettings settings) { void updateSettings(AppSettings settings) {
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5); final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
state = state.copyWith( state = state.copyWith(
@@ -2163,7 +2188,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
treeUri: treeUri, treeUri: treeUri,
relativeDir: relativeDir, relativeDir: relativeDir,
fileName: lrcName, fileName: lrcName,
mimeType: 'text/plain', mimeType: _mimeTypeForExt('.lrc'),
srcPath: tempPath, srcPath: tempPath,
); );
if (uri != null) { if (uri != null) {
@@ -2252,6 +2277,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await musicDir.create(recursive: true); await musicDir.create(recursive: true);
} }
state = state.copyWith(outputDir: musicDir.path); state = state.copyWith(outputDir: musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/)
_log.w(
'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
);
_log.w('Original path: ${state.outputDir}');
final correctedPath = await validateOrFixIosPath(state.outputDir);
_log.i('Corrected path: $correctedPath');
state = state.copyWith(outputDir: correctedPath);
} }
} }
@@ -2530,6 +2564,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
) )
: ''; : '';
String? appOutputDir; String? appOutputDir;
@@ -2540,6 +2575,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
); );
var effectiveOutputDir = initialOutputDir; var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode; var effectiveSafMode = isSafMode;
@@ -2577,7 +2613,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (deezerTrackId == null && if (deezerTrackId == null &&
trackToDownload.isrc != null && trackToDownload.isrc != null &&
trackToDownload.isrc!.isNotEmpty) { trackToDownload.isrc!.isNotEmpty &&
_isValidISRC(trackToDownload.isrc!)) {
try { try {
_log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}');
final deezerResult = await PlatformBridge.searchDeezerByISRC( final deezerResult = await PlatformBridge.searchDeezerByISRC(
@@ -2593,6 +2630,75 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
if (deezerTrackId == null &&
trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) {
try {
// Extract clean Spotify ID (remove spotify: prefix if present)
String spotifyId = trackToDownload.id;
if (spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.split(':').last;
}
_log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId');
final deezerData = await PlatformBridge.convertSpotifyToDeezer('track', spotifyId);
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
final trackData = deezerData['track'];
if (trackData is Map<String, dynamic>) {
final rawId = trackData['spotify_id'] as String?;
if (rawId != null && rawId.startsWith('deezer:')) {
deezerTrackId = rawId.split(':')[1];
_log.d('Found Deezer track ID via SongLink: $deezerTrackId');
} else if (deezerData['id'] != null) {
deezerTrackId = deezerData['id'].toString();
_log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId');
}
// Enrich track metadata from Deezer response (release_date, isrc, etc.)
final deezerReleaseDate = _normalizeOptionalString(trackData['release_date'] as String?);
final deezerIsrc = _normalizeOptionalString(trackData['isrc'] as String?);
final deezerTrackNum = trackData['track_number'] as int?;
final deezerDiscNum = trackData['disc_number'] as int?;
final needsEnrich =
(trackToDownload.releaseDate == null && deezerReleaseDate != null) ||
(trackToDownload.isrc == null && deezerIsrc != null) ||
(!_isValidISRC(trackToDownload.isrc ?? '') && deezerIsrc != null) ||
(trackToDownload.trackNumber == null && deezerTrackNum != null) ||
(trackToDownload.discNumber == null && deezerDiscNum != null);
if (needsEnrich) {
trackToDownload = Track(
id: trackToDownload.id,
name: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc
: trackToDownload.isrc,
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
deezerId: deezerTrackId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
source: trackToDownload.source,
);
_log.d('Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}');
}
} else if (deezerData['id'] != null) {
deezerTrackId = deezerData['id'].toString();
_log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId');
}
} catch (e) {
_log.w('Failed to convert Spotify to Deezer via SongLink: $e');
}
}
if (deezerTrackId != null && deezerTrackId.isNotEmpty) { if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
try { try {
final extendedMetadata = final extendedMetadata =
@@ -2628,6 +2734,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final fileName = useSaf ? (safFileName ?? '') : ''; final fileName = useSaf ? (safFileName ?? '') : '';
final outputExt = useSaf ? safOutputExt : ''; final outputExt = useSaf ? safOutputExt : '';
// YouTube provider - lossy only, bypasses fallback chain
if (item.service == 'youtube') {
_log.d('Using YouTube/Cobalt provider for download');
_log.d('Quality: $quality (lossy only)');
_log.d('Output dir: $outputDir');
return PlatformBridge.downloadFromYouTube(
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
itemId: item.id,
durationMs: trackToDownload.duration,
isrc: trackToDownload.isrc,
spotifyId: trackToDownload.id,
deezerId: deezerTrackId,
storageMode: storageMode,
safTreeUri: treeUri,
safRelativeDir: relativeDir,
safFileName: fileName,
safOutputExt: outputExt,
);
}
if (useExtensions) { if (useExtensions) {
_log.d('Using extension providers for download'); _log.d('Using extension providers for download');
_log.d( _log.d(
@@ -2736,6 +2872,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
); );
final fallbackResult = await runDownload( final fallbackResult = await runDownload(
useSaf: false, useSaf: false,
@@ -2813,6 +2950,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' &&
@@ -3225,6 +3363,109 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await File(tempPath).delete(); await File(tempPath).delete();
} catch (_) {} } catch (_) {}
} }
}
}
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
if (!wasExisting &&
item.service == 'youtube' &&
filePath != null) {
final isOpusFile = filePath.endsWith('.opus');
final isMp3File = filePath.endsWith('.mp3');
if (isOpusFile || isMp3File) {
_log.i('YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final isContentUriPath = isContentUri(filePath);
if (isContentUriPath && effectiveSafMode) {
// SAF mode: copy to temp, embed, write back
final tempPath = await _copySafToTemp(filePath);
if (tempPath != null) {
try {
if (isMp3File) {
await _embedMetadataToMp3(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
// Write back to SAF
final ext = isMp3File ? '.mp3' : '.opus';
final newFileName = '${safBaseName ?? 'track'}$ext';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt(ext),
srcPath: tempPath,
);
if (newUri != null) {
if (newUri != filePath) {
await _deleteSafFile(filePath);
}
filePath = newUri;
finalSafFileName = newFileName;
_log.d('YouTube SAF metadata embedding completed');
} else {
_log.w('Failed to write metadata-updated file back to SAF');
}
} catch (e) {
_log.w('YouTube SAF metadata embedding failed: $e');
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
}
}
} else {
// Non-SAF mode: embed directly
try {
if (isMp3File) {
await _embedMetadataToMp3(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('YouTube metadata embedding completed');
} catch (e) {
_log.w('YouTube metadata embedding failed: $e');
}
}
} }
} }
@@ -3444,6 +3685,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 +3734,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');
}
} }
} }
} }
+29 -6
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary'); final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at'; const _lastScannedAtKey = 'local_library_last_scanned_at';
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
class LocalLibraryState { class LocalLibraryState {
final List<LocalLibraryItem> items; final List<LocalLibraryItem> items;
@@ -22,6 +23,7 @@ class LocalLibraryState {
final int scanErrorCount; final int scanErrorCount;
final bool scanWasCancelled; final bool scanWasCancelled;
final DateTime? lastScannedAt; final DateTime? lastScannedAt;
final int excludedDownloadedCount;
final Set<String> _isrcSet; final Set<String> _isrcSet;
final Set<String> _trackKeySet; final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc; final Map<String, LocalLibraryItem> _byIsrc;
@@ -36,6 +38,7 @@ class LocalLibraryState {
this.scanErrorCount = 0, this.scanErrorCount = 0,
this.scanWasCancelled = false, this.scanWasCancelled = false,
this.lastScannedAt, this.lastScannedAt,
this.excludedDownloadedCount = 0,
}) : _isrcSet = items }) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty) .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!) .map((item) => item.isrc!)
@@ -81,6 +84,7 @@ class LocalLibraryState {
int? scanErrorCount, int? scanErrorCount,
bool? scanWasCancelled, bool? scanWasCancelled,
DateTime? lastScannedAt, DateTime? lastScannedAt,
int? excludedDownloadedCount,
}) { }) {
return LocalLibraryState( return LocalLibraryState(
items: items ?? this.items, items: items ?? this.items,
@@ -92,6 +96,8 @@ class LocalLibraryState {
scanErrorCount: scanErrorCount ?? this.scanErrorCount, scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
lastScannedAt: lastScannedAt ?? this.lastScannedAt, lastScannedAt: lastScannedAt ?? this.lastScannedAt,
excludedDownloadedCount:
excludedDownloadedCount ?? this.excludedDownloadedCount,
); );
} }
} }
@@ -126,19 +132,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList(); final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
DateTime? lastScannedAt; DateTime? lastScannedAt;
var excludedDownloadedCount = 0;
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final lastScannedAtStr = prefs.getString(_lastScannedAtKey); final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) { if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr); lastScannedAt = DateTime.tryParse(lastScannedAtStr);
} }
excludedDownloadedCount =
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
} catch (e) { } catch (e) {
_log.w('Failed to load lastScannedAt: $e'); _log.w('Failed to load lastScannedAt: $e');
} }
state = state.copyWith(items: items, lastScannedAt: lastScannedAt); state = state.copyWith(
items: items,
lastScannedAt: lastScannedAt,
excludedDownloadedCount: excludedDownloadedCount,
);
_log.i( _log.i(
'Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt', 'Loaded ${items.length} items from library database, lastScannedAt: '
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
); );
} catch (e, stack) { } catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack); _log.e('Failed to load library from database: $e', e, stack);
@@ -174,8 +188,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
try { try {
final cacheDir = await getApplicationCacheDirectory(); final appSupportDir = await getApplicationSupportDirectory();
final coverCacheDir = '${cacheDir.path}/library_covers'; final coverCacheDir = '${appSupportDir.path}/library_covers';
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir); await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
_log.i('Cover cache directory set to: $coverCacheDir'); _log.i('Cover cache directory set to: $coverCacheDir');
} catch (e) { } catch (e) {
@@ -226,6 +240,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String()); await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now'); _log.d('Saved lastScannedAt: $now');
} catch (e) { } catch (e) {
_log.w('Failed to save lastScannedAt: $e'); _log.w('Failed to save lastScannedAt: $e');
@@ -237,9 +252,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
); );
_log.i('Full scan complete: ${items.length} tracks found'); _log.i(
'Full scan complete: ${items.length} tracks found, '
'$skippedDownloads already in downloads',
);
} else { } else {
// Incremental scan path - only scans new/modified files // Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes(); final existingFiles = await _db.getFileModTimes();
@@ -344,6 +363,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String()); await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now'); _log.d('Saved lastScannedAt: $now');
} catch (e) { } catch (e) {
_log.w('Failed to save lastScannedAt: $e'); _log.w('Failed to save lastScannedAt: $e');
@@ -355,11 +375,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
); );
_log.i( _log.i(
'Incremental scan complete: ${items.length} total tracks ' 'Incremental scan complete: ${items.length} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)', '(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
); );
} }
} catch (e, stack) { } catch (e, stack) {
@@ -427,6 +449,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey); await prefs.remove(_lastScannedAtKey);
await prefs.remove(_excludedDownloadedCountKey);
} catch (e) { } catch (e) {
_log.w('Failed to clear lastScannedAt: $e'); _log.w('Failed to clear lastScannedAt: $e');
} }
+5
View File
@@ -226,6 +226,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) { void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode); state = state.copyWith(historyViewMode: mode);
_saveSettings(); _saveSettings();
+367 -110
View File
@@ -1,4 +1,5 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -14,7 +15,8 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen; import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionArtistScreen;
class _AlbumCache { class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {}; static final Map<String, _CacheEntry> _cache = {};
@@ -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),
@@ -219,11 +233,17 @@ Future<void> _fetchTracks() async {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -244,7 +264,9 @@ 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) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
@@ -258,25 +280,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: bottomGradientHeight,
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,
],
), ),
), ),
), ),
@@ -286,7 +318,7 @@ Future<void> _fetchTracks() async {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -303,7 +335,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 +343,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: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -320,7 +356,10 @@ Future<void> _fetchTracks() async {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
@@ -338,7 +377,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 +388,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 +398,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 +422,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 +490,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 +512,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 +553,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 +583,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 +649,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 +703,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 +724,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 +758,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 +767,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 +783,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 +815,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 +907,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 +924,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 +952,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 +968,54 @@ child: ListTile(
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)), value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
], ],
), ),
); );
} else if (isQueued) { } else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize)); return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else { } else {
return GestureDetector( return GestureDetector(
onTap: onDownload, onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
); );
} }
} }
File diff suppressed because it is too large Load Diff
+262 -105
View File
@@ -23,7 +23,8 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
}); });
@override @override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState(); ConsumerState<DownloadedAlbumScreen> createState() =>
_DownloadedAlbumScreenState();
} }
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> { class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
@@ -53,27 +54,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
/// Get tracks for this album from history provider (reactive) /// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) { List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
) {
return allItems.where((item) { return allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName // Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist! ? item.albumArtist!
: item.artistName; : item.artistName;
// Use lowercase for case-insensitive matching // Use lowercase for case-insensitive matching
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; final itemKey =
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey =
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
return itemKey == albumKey; return itemKey == albumKey;
}).toList() }).toList()..sort((a, b) {
..sort((a, b) { // Sort by disc number first, then by track number
// Sort by disc number first, then by track number final aDisc = a.discNumber ?? 1;
final aDisc = a.discNumber ?? 1; final bDisc = b.discNumber ?? 1;
final bDisc = b.discNumber ?? 1; if (aDisc != bDisc) return aDisc.compareTo(bDisc);
if (aDisc != bDisc) return aDisc.compareTo(bDisc); final aNum = a.trackNumber ?? 999;
final aNum = a.trackNumber ?? 999; final bNum = b.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999; if (aNum != bNum) return aNum.compareTo(bNum);
if (aNum != bNum) return aNum.compareTo(bNum); return a.trackName.compareTo(b.trackName);
return a.trackName.compareTo(b.trackName); });
});
} }
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc( Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
@@ -164,7 +169,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))), SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
); );
} }
} }
@@ -176,7 +183,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))), SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
); );
} }
} }
@@ -184,12 +193,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void _navigateToMetadataScreen(DownloadHistoryItem item) { void _navigateToMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
Navigator.push(context, PageRouteBuilder( Navigator.push(
transitionDuration: const Duration(milliseconds: 300), context,
reverseTransitionDuration: const Duration(milliseconds: 250), PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), transitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), reverseTransitionDuration: const Duration(milliseconds: 250),
)); pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
} }
void _precacheCover(String? url) { void _precacheCover(String? url) {
@@ -208,18 +222,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom; final bottomPadding = MediaQuery.of(context).padding.bottom;
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final tracks = _getAlbumTracks(allHistoryItems); final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found // Show empty state if no tracks found
if (tracks.isEmpty) { if (tracks.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(widget.albumName)),
title: Text(widget.albumName), body: Center(child: Text('No tracks found for this album')),
),
body: Center(
child: Text('No tracks found for this album'),
),
); );
} }
@@ -248,7 +260,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks), _buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
], ],
), ),
@@ -258,7 +272,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
left: 0, left: 0,
right: 0, right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding), child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
), ),
], ],
), ),
@@ -267,14 +286,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; // 50% of screen width final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
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),
@@ -292,7 +318,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
@@ -306,25 +334,35 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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: bottomGradientHeight,
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,
],
), ),
), ),
), ),
@@ -335,7 +373,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -352,7 +390,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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(),
@@ -360,7 +398,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -369,14 +411,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -384,14 +432,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
} }
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -399,32 +453,59 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
widget.artistName, widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer), Icon(
Icons.download_done,
size: 14,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
context.l10n.downloadedAlbumDownloadedCount(
tracks.length,
),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (_getCommonQuality(tracks) != null) if (_getCommonQuality(tracks) != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24') color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer ? colorScheme.tertiaryContainer
@@ -462,7 +543,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return firstQuality; return firstQuality;
} }
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -470,14 +555,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), Text(
context.l10n.downloadedAlbumTracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(), const Spacer(),
if (!_isSelectionMode) if (!_isSelectionMode)
TextButton.icon( TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null, onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18), icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect), label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact), style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
), ),
], ],
), ),
@@ -485,21 +580,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
} }
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final discMap = _groupTracksByDisc(tracks); final discMap = _groupTracksByDisc(tracks);
if (discMap.length <= 1) { if (discMap.length <= 1) {
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: _buildTrackItem(context, colorScheme, track),
child: _buildTrackItem(context, colorScheme, track), );
); }, childCount: tracks.length),
},
childCount: tracks.length,
),
); );
} }
@@ -524,12 +620,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
} }
return SliverList( return SliverList(delegate: SliverChildListDelegate(children));
delegate: SliverChildListDelegate(children),
);
} }
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) { Widget _buildDiscSeparator(
BuildContext context,
ColorScheme colorScheme,
int discNumber,
) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row( child: Row(
@@ -543,7 +641,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer), Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
context.l10n.downloadedAlbumDiscHeader(discNumber), context.l10n.downloadedAlbumDiscHeader(discNumber),
@@ -567,21 +669,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
} }
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) { Widget _buildTrackItem(
BuildContext context,
ColorScheme colorScheme,
DownloadHistoryItem track,
) {
final isSelected = _selectedIds.contains(track.id); final isSelected = _selectedIds.contains(track.id);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent, color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: 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),
),
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track), : () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -590,12 +702,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
width: 24, width: 24,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent, color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
), ),
child: isSelected child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) ? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null, : null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -617,7 +740,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
track.trackName, track.trackName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
), ),
subtitle: Text( subtitle: Text(
track.artistName, track.artistName,
@@ -625,19 +750,28 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant), style: TextStyle(color: colorScheme.onSurfaceVariant),
), ),
trailing: _isSelectionMode ? null : IconButton( trailing: _isSelectionMode
onPressed: () => _openFile(track.filePath), ? null
icon: Icon(Icons.play_arrow, color: colorScheme.primary), : IconButton(
style: IconButton.styleFrom( onPressed: () => _openFile(track.filePath),
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), icon: Icon(Icons.play_arrow, color: colorScheme.primary),
), style: IconButton.styleFrom(
), backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
), ),
), ),
); );
} }
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) { Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length; final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
@@ -684,12 +818,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount), context.l10n.downloadedAlbumSelectedCount(
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
Text( Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect, allSelected
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
), ),
], ],
), ),
@@ -702,9 +842,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_selectAll(tracks); _selectAll(tracks);
} }
}, },
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20), icon: Icon(
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), allSelected ? Icons.deselect : Icons.select_all,
style: TextButton.styleFrom(foregroundColor: colorScheme.primary), size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
), ),
], ],
), ),
@@ -712,7 +861,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
label: Text( label: Text(
selectedCount > 0 selectedCount > 0
@@ -720,10 +871,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: context.l10n.downloadedAlbumSelectToDelete, : context.l10n.downloadedAlbumSelectToDelete,
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, backgroundColor: selectedCount > 0
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, ? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
), ),
), ),
+1504 -955
View File
File diff suppressed because it is too large Load Diff
+263 -81
View File
@@ -89,7 +89,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_hasMultipleDiscsCache = _discGroupsCache.length > 1; _hasMultipleDiscsCache = _discGroupsCache.length > 1;
} }
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) { Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
List<LocalLibraryItem> tracks,
) {
final discMap = <int, List<LocalLibraryItem>>{}; final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) { for (final track in tracks) {
final discNumber = track.discNumber ?? 1; final discNumber = track.discNumber ?? 1;
@@ -175,7 +177,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))), SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
); );
// Go back if all tracks were deleted // Go back if all tracks were deleted
@@ -192,7 +196,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))), SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
); );
} }
} }
@@ -207,12 +213,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
// Show empty state if no tracks found // Show empty state if no tracks found
if (tracks.isEmpty) { if (tracks.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(widget.albumName)),
title: Text(widget.albumName), body: const Center(child: Text('No tracks found for this album')),
),
body: const Center(
child: Text('No tracks found for this album'),
),
); );
} }
@@ -241,7 +243,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks), _buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
], ],
), ),
@@ -251,7 +255,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
left: 0, left: 0,
right: 0, right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding), child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
), ),
], ],
), ),
@@ -260,11 +269,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -285,7 +300,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
@@ -298,24 +315,33 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Image.file( Image.file(
File(widget.coverPath!), File(widget.coverPath!),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface), errorBuilder: (_, _, _) =>
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: bottomGradientHeight,
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,
],
), ),
), ),
), ),
@@ -326,7 +352,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -349,13 +375,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
cacheWidth: (coverSize * 2).toInt(), cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) => errorBuilder: (context, error, stackTrace) =>
Container( Container(
color: colorScheme.surfaceContainerHighest, color:
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -364,14 +399,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -379,14 +420,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
); );
} }
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) { Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -394,40 +441,79 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
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,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
widget.artistName, widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
// "Local" badge // "Local" badge
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.folder, size: 14, color: colorScheme.onTertiaryContainer), Icon(
Icons.folder,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
'Local',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Track count // Track count
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant), Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)), Text(
'${tracks.length} tracks',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
@@ -435,7 +521,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
// Quality badge if all tracks have the same quality // Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null) if (_getCommonQuality(tracks) != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24') color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer ? colorScheme.primaryContainer
@@ -468,16 +557,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final first = tracks.first; final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null; if (first.bitDepth == null || first.sampleRate == null) return null;
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
for (final track in tracks) { for (final track in tracks) {
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) { if (track.bitDepth != first.bitDepth ||
track.sampleRate != first.sampleRate) {
return null; return null;
} }
} }
return firstQuality; return firstQuality;
} }
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) { Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -485,14 +580,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
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.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), Text(
context.l10n.downloadedAlbumTracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(), const Spacer(),
if (!_isSelectionMode) if (!_isSelectionMode)
TextButton.icon( TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null, onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18), icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect), label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact), style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
), ),
], ],
), ),
@@ -500,7 +605,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
); );
} }
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) { Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
final discGroups = _discGroupsCache; final discGroups = _discGroupsCache;
final hasMultipleDiscs = _hasMultipleDiscsCache; final hasMultipleDiscs = _hasMultipleDiscsCache;
@@ -517,7 +626,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.secondaryContainer, color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -525,14 +637,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer), Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
context.l10n.downloadedAlbumDiscHeader(discNumber), context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: Theme.of(context).textTheme.labelLarge
color: colorScheme.onSecondaryContainer, ?.copyWith(
fontWeight: FontWeight.w600, color: colorScheme.onSecondaryContainer,
), fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@@ -554,7 +671,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
slivers.add( slivers.add(
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]), (context, index) =>
_buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length, childCount: discTracks.length,
), ),
), ),
@@ -564,21 +682,31 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return SliverMainAxisGroup(slivers: slivers); return SliverMainAxisGroup(slivers: slivers);
} }
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) { Widget _buildTrackItem(
BuildContext context,
ColorScheme colorScheme,
LocalLibraryItem track,
) {
final isSelected = _selectedIds.contains(track.id); final isSelected = _selectedIds.contains(track.id);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent, color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: 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),
),
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _openFile(track.filePath), : () => _openFile(track.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -587,12 +715,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
width: 24, width: 24,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent, color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
), ),
child: isSelected child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) ? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null, : null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -614,7 +753,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
track.trackName, track.trackName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
), ),
subtitle: Row( subtitle: Row(
children: [ children: [
@@ -627,27 +768,45 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
), ),
if (track.format != null) ...[ if (track.format != null) ...[
Text('', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)), Text(
'',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text( Text(
track.format!.toUpperCase(), track.format!.toUpperCase(),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12), style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
), ),
], ],
], ],
), ),
trailing: _isSelectionMode ? null : IconButton( trailing: _isSelectionMode
onPressed: () => _openFile(track.filePath), ? null
icon: Icon(Icons.play_arrow, color: colorScheme.primary), : IconButton(
style: IconButton.styleFrom( onPressed: () => _openFile(track.filePath),
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), icon: Icon(Icons.play_arrow, color: colorScheme.primary),
), style: IconButton.styleFrom(
), backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
), ),
), ),
); );
} }
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks, double bottomPadding) { Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length; final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
@@ -694,12 +853,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount), context.l10n.downloadedAlbumSelectedCount(
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
Text( Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect, allSelected
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
), ),
], ],
), ),
@@ -712,9 +877,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_selectAll(tracks); _selectAll(tracks);
} }
}, },
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20), icon: Icon(
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), allSelected ? Icons.deselect : Icons.select_all,
style: TextButton.styleFrom(foregroundColor: colorScheme.primary), size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
), ),
], ],
), ),
@@ -722,7 +896,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
label: Text( label: Text(
selectedCount > 0 selectedCount > 0
@@ -730,10 +906,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
: context.l10n.downloadedAlbumSelectToDelete, : context.l10n.downloadedAlbumSelectToDelete,
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, backgroundColor: selectedCount > 0
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, ? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
), ),
), ),
+23 -1
View File
@@ -390,7 +390,9 @@ class _MainShellState extends ConsumerState<MainShell> {
body: PageView( body: PageView(
controller: _pageController, controller: _pageController,
onPageChanged: _onPageChanged, onPageChanged: _onPageChanged,
physics: const ClampingScrollPhysics(), physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
? const _NoSwipeRightPhysics()
: const ClampingScrollPhysics(),
children: tabs, children: tabs,
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
@@ -413,6 +415,26 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
} }
/// Custom physics that blocks swiping to the right (next page) while
/// still allowing vertical scrolling inside the page content.
class _NoSwipeRightPhysics extends ScrollPhysics {
const _NoSwipeRightPhysics({super.parent});
@override
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
// In a horizontal PageView, a negative offset means the user is
// dragging left (i.e. trying to go to the next page / right).
// Block that direction only.
if (offset < 0) return 0.0;
return super.applyPhysicsToUserOffset(position, offset);
}
}
class BouncingIcon extends StatefulWidget { class BouncingIcon extends StatefulWidget {
final Widget child; final Widget child;
const BouncingIcon({super.key, required this.child}); const BouncingIcon({super.key, required this.child});
+341 -85
View File
@@ -1,4 +1,5 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -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;
@@ -139,14 +145,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; // 50% of screen width final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
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 +177,9 @@ 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) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
@@ -178,25 +193,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: bottomGradientHeight,
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,
],
), ),
), ),
), ),
@@ -207,7 +232,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -224,7 +249,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 +257,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: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -241,7 +270,10 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
@@ -266,34 +298,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 +373,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 +408,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 +437,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 +459,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 +488,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 +527,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 +562,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 +572,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 +631,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 +723,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 +740,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 +768,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 +784,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,
),
),
); );
} }
} }
+721 -376
View File
File diff suppressed because it is too large Load Diff
+308 -267
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget { class AboutPage extends StatelessWidget {
@@ -12,7 +13,7 @@ class AboutPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, canPop: true,
@@ -20,211 +21,229 @@ class AboutPage extends StatelessWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 120 + topPadding, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxHeight = 120 + topPadding; final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding; final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final expandRatio =
final leftPadding = 56 - (32 * expandRatio); ((constraints.maxHeight - minHeight) /
return FlexibleSpaceBar( (maxHeight - minHeight))
expandedTitleScale: 1.0, .clamp(0.0, 1.0);
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), final leftPadding = 56 - (32 * expandRatio);
title: Text( return FlexibleSpaceBar(
context.l10n.aboutTitle, expandedTitleScale: 1.0,
style: TextStyle( titlePadding: EdgeInsets.only(
fontSize: 20 + (8 * expandRatio), // 20 -> 28 left: leftPadding,
fontWeight: FontWeight.bold, bottom: 16,
color: colorScheme.onSurface,
), ),
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: _AppHeaderCard(),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutContributors,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
), ),
); _ContributorItem(
}, name: AppInfo.originalAuthor,
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
],
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: SettingsSectionHeader(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), title: context.l10n.aboutTranslators,
child: _AppHeaderCard(), ),
), ),
), const SliverToBoxAdapter(child: _TranslatorsSection()),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors), child: SettingsSectionHeader(
), title: context.l10n.aboutSpecialThanks,
SliverToBoxAdapter( ),
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
_ContributorItem(
name: AppInfo.originalAuthor,
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
],
), ),
), SliverToBoxAdapter(
child: SettingsGroup(
SliverToBoxAdapter( children: [
child: SettingsSectionHeader(title: context.l10n.aboutTranslators), _ContributorItem(
), name: 'binimum',
const SliverToBoxAdapter( description: context.l10n.aboutBinimumDesc,
child: _TranslatorsSection(), githubUsername: 'binimum',
), showDivider: true,
),
SliverToBoxAdapter( _ContributorItem(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks), name: 'sachinsenal0x64',
), description: context.l10n.aboutSachinsenalDesc,
SliverToBoxAdapter( githubUsername: 'sachinsenal0x64',
child: SettingsGroup( showDivider: true,
children: [ ),
_ContributorItem( _ContributorItem(
name: 'binimum', name: 'sjdonado',
description: context.l10n.aboutBinimumDesc, description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'binimum', githubUsername: 'sjdonado',
showDivider: true, showDivider: true,
), ),
_ContributorItem( _AboutSettingsItem(
name: 'sachinsenal0x64', icon: Icons.music_note_outlined,
description: context.l10n.aboutSachinsenalDesc, title: context.l10n.aboutDabMusic,
githubUsername: 'sachinsenal0x64', subtitle: context.l10n.aboutDabMusicDesc,
showDivider: true, onTap: () => _launchUrl('https://dabmusic.xyz'),
), showDivider: true,
_ContributorItem( ),
name: 'sjdonado', _AboutSettingsItem(
description: context.l10n.aboutSjdonadoDesc, icon: Icons.music_note_outlined,
githubUsername: 'sjdonado', title: context.l10n.aboutSpotiSaver,
showDivider: true, subtitle: context.l10n.aboutSpotiSaverDesc,
), onTap: () => _launchUrl('https://spotisaver.net'),
_AboutSettingsItem( showDivider: false,
icon: Icons.music_note_outlined, ),
title: context.l10n.aboutDabMusic, ],
subtitle: context.l10n.aboutDabMusicDesc, ),
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: false,
),
],
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks), child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.phone_android,
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.computer,
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
), ),
), SliverToBoxAdapter(
child: SettingsGroup(
SliverToBoxAdapter( children: [
child: SettingsSectionHeader(title: context.l10n.aboutSocial), _AboutSettingsItem(
), icon: Icons.phone_android,
SliverToBoxAdapter( title: context.l10n.aboutMobileSource,
child: SettingsGroup( subtitle: 'github.com/${AppInfo.githubRepo}',
children: [ onTap: () => _launchUrl(AppInfo.githubUrl),
_AboutSettingsItem( showDivider: true,
icon: Icons.telegram, ),
title: context.l10n.aboutTelegramChannel, _AboutSettingsItem(
subtitle: context.l10n.aboutTelegramChannelSubtitle, icon: Icons.computer,
onTap: () => _launchUrl('https://t.me/spotiflac'), title: context.l10n.aboutPCSource,
showDivider: true, subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
), onTap: () => _launchUrl(AppInfo.originalGithubUrl),
_AboutSettingsItem( showDivider: true,
icon: Icons.forum_outlined, ),
title: context.l10n.aboutTelegramChat, _AboutSettingsItem(
subtitle: context.l10n.aboutTelegramChatSubtitle, icon: Icons.bug_report_outlined,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'), title: context.l10n.aboutReportIssue,
showDivider: false, subtitle: context.l10n.aboutReportIssueSubtitle,
), onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
], showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp), child: SettingsSectionHeader(title: context.l10n.aboutSocial),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
_AboutSettingsItem( _AboutSettingsItem(
icon: Icons.info_outline, icon: Icons.telegram,
title: context.l10n.aboutVersion, title: context.l10n.aboutTelegramChannel,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})', subtitle: context.l10n.aboutTelegramChannelSubtitle,
showDivider: false, onTap: () => _launchUrl('https://t.me/spotiflac'),
), showDivider: true,
], ),
_AboutSettingsItem(
icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'),
showDivider: false,
),
],
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: SettingsSectionHeader(title: context.l10n.aboutApp),
padding: const EdgeInsets.all(24), ),
child: Center( SliverToBoxAdapter(
child: Text( child: SettingsGroup(
AppInfo.copyright, children: [
style: Theme.of(context).textTheme.bodySmall?.copyWith( _AboutSettingsItem(
color: colorScheme.onSurfaceVariant, icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle:
'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
), ),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter(child: SizedBox(height: 16)),
], ],
),
), ),
),
); );
} }
@@ -241,71 +260,91 @@ class _AppHeaderCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest; : colorScheme.surfaceContainerHighest;
return Container( return LayoutBuilder(
decoration: BoxDecoration( builder: (context, constraints) {
color: cardColor, final cardWidth = constraints.maxWidth;
borderRadius: BorderRadius.circular(20), final shortestSide = MediaQuery.sizeOf(context).shortestSide;
), final textScale = MediaQuery.textScalerOf(
padding: const EdgeInsets.all(24), context,
child: Column( ).scale(1.0).clamp(1.0, 1.4);
children: [ final logoSize = (shortestSide * 0.22).clamp(72.0, 88.0);
Container( final contentPadding = (cardWidth * 0.06).clamp(16.0, 24.0);
width: 88, final titleGap = (16 * (1 + ((textScale - 1) * 0.2))).clamp(12.0, 20.0);
height: 88,
decoration: BoxDecoration( return Container(
color: colorScheme.primary, decoration: BoxDecoration(
shape: BoxShape.circle, color: cardColor,
), borderRadius: BorderRadius.circular(20),
child: Image.asset( ),
'assets/images/logo-transparant.png', padding: EdgeInsets.all(contentPadding),
color: colorScheme.onPrimary, child: Column(
fit: BoxFit.contain, children: [
errorBuilder: (_, _, _) => ClipRRect( Container(
borderRadius: BorderRadius.circular(24), width: logoSize,
height: logoSize,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset( child: Image.asset(
'assets/images/logo.png', 'assets/images/logo-transparant.png',
width: 88, color: colorScheme.onPrimary,
height: 88, fit: BoxFit.contain,
fit: BoxFit.cover, errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: logoSize,
height: logoSize,
fit: BoxFit.cover,
),
),
), ),
), ),
), SizedBox(height: titleGap),
), Text(
const SizedBox(height: 16), AppInfo.appName,
Text( textAlign: TextAlign.center,
AppInfo.appName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(
style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, ),
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
), ),
), const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(height: titleGap),
Text(
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
), ),
const SizedBox(height: 16), );
Text( },
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
); );
} }
} }
@@ -340,7 +379,7 @@ class _ContributorItem extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: 'https://github.com/$githubUsername.png', imageUrl: 'https://github.com/$githubUsername.png',
width: 40, width: 40,
height: 40, height: 40,
@@ -373,10 +412,7 @@ child: CachedNetworkImage(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(name, style: Theme.of(context).textTheme.bodyLarge),
name,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
description, description,
@@ -480,7 +516,10 @@ class _TranslatorsSection extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest; : colorScheme.surfaceContainerHighest;
return Padding( return Padding(
@@ -494,9 +533,9 @@ class _TranslatorsSection extends StatelessWidget {
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: _translators.map((translator) => _TranslatorChip( children: _translators
translator: translator, .map((translator) => _TranslatorChip(translator: translator))
)).toList(), .toList(),
), ),
), ),
); );
@@ -528,7 +567,9 @@ class _TranslatorChip extends StatelessWidget {
radius: 10, radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2), backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text( child: Text(
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?', translator.name.isNotEmpty
? translator.name[0].toUpperCase()
: '?',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -545,10 +586,7 @@ class _TranslatorChip extends StatelessWidget {
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(translator.flag, style: const TextStyle(fontSize: 14)),
translator.flag,
style: const TextStyle(fontSize: 14),
),
], ],
), ),
), ),
@@ -595,31 +633,34 @@ class _AboutSettingsItem extends StatelessWidget {
SizedBox( SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), child: Icon(
icon,
color: colorScheme.onSurfaceVariant,
size: 24,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(title, style: Theme.of(context).textTheme.bodyLarge),
title,
style: Theme.of(context).textTheme.bodyLarge,
),
if (subtitle != null) ...[ if (subtitle != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
subtitle!, subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
color: colorScheme.onSurfaceVariant, ?.copyWith(color: colorScheme.onSurfaceVariant),
),
), ),
], ],
], ],
), ),
), ),
if (onTap != null) if (onTap != null)
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
], ],
), ),
), ),
+171 -161
View File
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/l10n/supported_locales.dart'; import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class AppearanceSettingsPage extends ConsumerWidget { class AppearanceSettingsPage extends ConsumerWidget {
@@ -14,7 +15,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
final themeSettings = ref.watch(themeProvider); final themeSettings = ref.watch(themeProvider);
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, canPop: true,
@@ -22,21 +23,21 @@ class AppearanceSettingsPage extends ConsumerWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 120 + topPadding, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
), ),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -77,8 +78,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) => onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color), ref.read(themeProvider.notifier).setSeedColor(color),
), ),
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme), child: SettingsSectionHeader(title: context.l10n.sectionTheme),
@@ -113,9 +114,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [ children: [
_LanguageSelector( _LanguageSelector(
currentLocale: settings.locale, currentLocale: settings.locale,
onChanged: (locale) => ref onChanged: (locale) =>
.read(settingsProvider.notifier) ref.read(settingsProvider.notifier).setLocale(locale),
.setLocale(locale),
), ),
], ],
), ),
@@ -156,151 +156,167 @@ class _ThemePreviewCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return RepaintBoundary( return RepaintBoundary(
child: Container( child: LayoutBuilder(
height: 200, builder: (context, constraints) {
width: double.infinity, final cardWidth = constraints.maxWidth;
decoration: BoxDecoration( final previewHeight = (cardWidth * 0.56).clamp(170.0, 220.0);
color: colorScheme final innerWidth = (cardWidth - 48).clamp(220.0, 320.0);
.surfaceContainerHighest, final innerHeight = (previewHeight * 0.70).clamp(120.0, 160.0);
borderRadius: BorderRadius.circular(28), final innerPadding = (innerHeight * 0.11).clamp(12.0, 18.0);
), final artworkSize = (innerHeight - (innerPadding * 2)).clamp(
clipBehavior: Clip.antiAlias, 80.0,
child: Stack( 120.0,
children: [ );
Positioned(
top: -50,
right: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
),
),
),
Center( return Container(
child: Container( constraints: BoxConstraints(minHeight: previewHeight),
width: 260, width: double.infinity,
height: 140, decoration: BoxDecoration(
padding: const EdgeInsets.all(16), color: colorScheme.surfaceContainerHighest,
decoration: BoxDecoration( borderRadius: BorderRadius.circular(28),
color: colorScheme.surface, ),
borderRadius: BorderRadius.circular(20), clipBehavior: Clip.antiAlias,
boxShadow: [ child: Stack(
BoxShadow( children: [
color: Colors.black.withValues(alpha: 0.1), Positioned(
blurRadius: 12, top: -(previewHeight * 0.25),
offset: const Offset(0, 8), right: -(previewHeight * 0.25),
), child: Container(
], width: previewHeight,
), height: previewHeight,
child: Row( decoration: BoxDecoration(
children: [ shape: BoxShape.circle,
Container( color: colorScheme.primaryContainer.withValues(
width: 108, alpha: 0.5,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
), ),
), ),
const SizedBox(width: 16), ),
),
Expanded( Positioned(
child: Column( bottom: -(previewHeight * 0.15),
crossAxisAlignment: CrossAxisAlignment.start, left: -(previewHeight * 0.15),
mainAxisAlignment: MainAxisAlignment.center, child: Container(
children: [ width: previewHeight * 0.75,
Container( height: previewHeight * 0.75,
width: double.infinity, decoration: BoxDecoration(
height: 14, shape: BoxShape.circle,
decoration: BoxDecoration( color: colorScheme.tertiaryContainer.withValues(
color: colorScheme.onSurface, alpha: 0.5,
borderRadius: BorderRadius.circular(4), ),
), ),
),
),
Center(
child: Container(
width: innerWidth,
height: innerHeight,
padding: EdgeInsets.all(innerPadding),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
width: artworkSize,
height: artworkSize,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
), ),
const SizedBox(height: 8), child: Icon(
Container( Icons.music_note,
width: 80, color: colorScheme.onPrimary,
height: 10, size: artworkSize * 0.44,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(height: 24), ),
Row( const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Container(
Icons.skip_previous, width: double.infinity,
size: 24, height: 14,
color: colorScheme.onSurfaceVariant, decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(width: 12), const SizedBox(height: 8),
Icon( Container(
Icons.play_circle_fill, width: 80,
size: 32, height: 10,
color: colorScheme.primary, decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(width: 12), const SizedBox(height: 24),
Icon( Row(
Icons.skip_next, children: [
size: 24, Icon(
color: colorScheme.onSurfaceVariant, Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
),
],
), ),
], ],
), ),
], ),
), ],
), ),
],
),
),
),
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
), ),
), ),
), Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark
? context.l10n.appearanceThemeDark
: context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
), ),
], );
), },
), ),
); );
} }
@@ -694,7 +710,7 @@ class _LanguageSelector extends StatelessWidget {
required this.onChanged, required this.onChanged,
}); });
static const _allLanguages = [ static const _allLanguages = [
('system', 'System Default', Icons.phone_android), ('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language), ('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language), ('id', 'Bahasa Indonesia', Icons.language),
@@ -735,16 +751,10 @@ static const _allLanguages = [
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return ListTile( return ListTile(
leading: Icon( leading: Icon(Icons.language, color: colorScheme.onSurfaceVariant),
Icons.language,
color: colorScheme.onSurfaceVariant,
),
title: Text(context.l10n.appearanceLanguage), title: Text(context.l10n.appearanceLanguage),
subtitle: Text(_getLanguageName(currentLocale)), subtitle: Text(_getLanguageName(currentLocale)),
trailing: Icon( trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
onTap: () => _showLanguagePicker(context), onTap: () => _showLanguagePicker(context),
); );
} }
@@ -765,9 +775,9 @@ static const _allLanguages = [
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text( child: Text(
context.l10n.appearanceLanguage, context.l10n.appearanceLanguage,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
@@ -0,0 +1,675 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class CacheManagementPage extends ConsumerStatefulWidget {
const CacheManagementPage({super.key});
@override
ConsumerState<CacheManagementPage> createState() =>
_CacheManagementPageState();
}
class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
// Keep in sync with ExploreNotifier keys.
static const String _exploreCacheKey = 'explore_home_feed_cache';
static const String _exploreCacheTsKey = 'explore_home_feed_ts';
_CacheOverview? _overview;
bool _isLoading = true;
String? _busyAction;
@override
void initState() {
super.initState();
_refreshOverview();
}
bool get _isBusy => _busyAction != null;
Future<void> _refreshOverview() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final overview = await _buildOverview();
if (!mounted) return;
setState(() {
_overview = overview;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
Future<_CacheOverview> _buildOverview() async {
final appCacheDir = await getApplicationCacheDirectory();
final tempDir = await getTemporaryDirectory();
final appCachePath = p.normalize(appCacheDir.path);
final tempPath = p.normalize(tempDir.path);
final tempIsSameAsAppCache = appCachePath == tempPath;
final appCacheStats = await _scanDirectory(Directory(appCachePath));
final tempStats = tempIsSameAsAppCache
? null
: await _scanDirectory(Directory(tempPath));
final coverStats = await CoverCacheManager.getStats();
final prefs = await SharedPreferences.getInstance();
final explorePayload = prefs.getString(_exploreCacheKey);
final exploreTs = prefs.getInt(_exploreCacheTsKey);
var exploreBytes = 0;
if (explorePayload != null && explorePayload.isNotEmpty) {
exploreBytes += utf8.encode(explorePayload).length;
}
if (exploreTs != null) {
exploreBytes += 8;
}
final hasExploreCache = exploreBytes > 0;
int trackCacheEntries;
try {
trackCacheEntries = await PlatformBridge.getTrackCacheSize();
} catch (_) {
trackCacheEntries = 0;
}
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
final libraryCoverStats = await _scanDirectory(libraryCoverDir);
return _CacheOverview(
appCachePath: appCachePath,
appCacheStats: appCacheStats,
tempPath: tempIsSameAsAppCache ? null : tempPath,
tempStats: tempStats,
tempIsSameAsAppCache: tempIsSameAsAppCache,
coverStats: coverStats,
libraryCoverStats: libraryCoverStats,
exploreCacheBytes: exploreBytes,
hasExploreCache: hasExploreCache,
trackCacheEntries: trackCacheEntries,
);
}
Future<_DirectoryStats> _scanDirectory(Directory directory) async {
if (!await directory.exists()) {
return const _DirectoryStats(fileCount: 0, totalSizeBytes: 0);
}
var fileCount = 0;
var totalSize = 0;
try {
await for (final entity in directory.list(
recursive: true,
followLinks: false,
)) {
if (entity is File) {
fileCount++;
totalSize += await entity.length();
}
}
} catch (_) {}
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
}
Future<void> _clearDirectoryContents(String path) async {
final directory = Directory(path);
if (!await directory.exists()) return;
try {
final entities = directory.listSync(followLinks: false);
for (final entity in entities) {
try {
await entity.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
try {
await directory.create(recursive: true);
} catch (_) {}
}
Future<void> _clearAppCache() async {
final cacheDir = await getApplicationCacheDirectory();
await _clearDirectoryContents(cacheDir.path);
}
Future<void> _clearTempCache() async {
final tempDir = await getTemporaryDirectory();
await _clearDirectoryContents(tempDir.path);
}
Future<void> _clearCoverCache() async {
await CoverCacheManager.clearCache();
}
Future<void> _clearLibraryCoverCache() async {
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
await _clearDirectoryContents(libraryCoverDir.path);
}
Future<void> _clearExploreCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_exploreCacheKey);
await prefs.remove(_exploreCacheTsKey);
}
Future<void> _clearTrackCache() async {
await PlatformBridge.clearTrackCache();
}
Future<void> _clearAllCaches() async {
final currentOverview = _overview;
await _clearAppCache();
if (currentOverview != null && !currentOverview.tempIsSameAsAppCache) {
await _clearTempCache();
}
await _clearCoverCache();
await _clearLibraryCoverCache();
await _clearExploreCache();
await _clearTrackCache();
}
Future<bool> _confirmClear(String target) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.cacheClearConfirmTitle),
content: Text(context.l10n.cacheClearConfirmMessage(target)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogClear),
),
],
),
);
return confirm == true;
}
Future<bool> _confirmClearAll() async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.cacheClearAllConfirmTitle),
content: Text(context.l10n.cacheClearAllConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogClear),
),
],
),
);
return confirm == true;
}
Future<void> _runAction(
String actionKey,
Future<void> Function() action, {
String? successMessage,
}) async {
if (_isBusy || !mounted) return;
setState(() => _busyAction = actionKey);
try {
await action();
if (!mounted) return;
if (successMessage != null && successMessage.isNotEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(successMessage)));
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
if (mounted) {
setState(() => _busyAction = null);
await _refreshOverview();
}
}
}
Future<void> _confirmAndRunAction({
required String actionKey,
required String targetLabel,
required Future<void> Function() action,
}) async {
final confirmed = await _confirmClear(targetLabel);
if (!confirmed) return;
if (!mounted) return;
await _runAction(
actionKey,
action,
successMessage: context.l10n.cacheClearSuccess(targetLabel),
);
}
Future<void> _cleanupUnusedData() async {
await _runAction('cleanup_unused', () async {
final orphanedDownloads = await ref
.read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads();
final missingLibraryEntries = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.cacheCleanupResult(
orphanedDownloads,
missingLibraryEntries,
),
),
),
);
});
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
String _formatDirectorySize(_DirectoryStats stats) {
if (stats.fileCount == 0 || stats.totalSizeBytes == 0) {
return context.l10n.cacheNoData;
}
return context.l10n.cacheSizeWithFiles(
_formatBytes(stats.totalSizeBytes),
stats.fileCount,
);
}
String _buildSubtitle(String description, String sizeInfo) {
return '$description\n$sizeInfo';
}
Widget _buildClearTrailing(String actionKey, VoidCallback onPressed) {
if (_busyAction == actionKey) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
return TextButton(
onPressed: _isBusy ? null : onPressed,
child: Text(context.l10n.dialogClear),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final overview = _overview;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.cacheTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
if (_isLoading || overview == null)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else ...[
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.cacheSummaryTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 6),
Text(
context.l10n.cacheEstimatedTotal(
_formatBytes(overview.totalKnownDiskCacheBytes),
),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 2),
Text(
context.l10n.cacheSummarySubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.85,
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _isBusy
? null
: () async {
final l10n = context.l10n;
final confirmed = await _confirmClearAll();
if (!confirmed) return;
if (!mounted) return;
await _runAction(
'clear_all',
_clearAllCaches,
successMessage: l10n.cacheClearSuccess(
l10n.cacheClearAll,
),
);
},
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(context.l10n.cacheClearAll),
),
OutlinedButton.icon(
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
label: Text(context.l10n.cacheRefreshStats),
),
],
),
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.cacheSectionStorage,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.cacheAppDirectory,
subtitle: _buildSubtitle(
context.l10n.cacheAppDirectoryDesc,
_formatDirectorySize(overview.appCacheStats),
),
trailing: _buildClearTrailing(
'clear_app_cache',
() => _confirmAndRunAction(
actionKey: 'clear_app_cache',
targetLabel: context.l10n.cacheAppDirectory,
action: _clearAppCache,
),
),
),
if (!overview.tempIsSameAsAppCache &&
overview.tempStats != null)
SettingsItem(
icon: Icons.timer_outlined,
title: context.l10n.cacheTempDirectory,
subtitle: _buildSubtitle(
context.l10n.cacheTempDirectoryDesc,
_formatDirectorySize(overview.tempStats!),
),
trailing: _buildClearTrailing(
'clear_temp_cache',
() => _confirmAndRunAction(
actionKey: 'clear_temp_cache',
targetLabel: context.l10n.cacheTempDirectory,
action: _clearTempCache,
),
),
),
SettingsItem(
icon: Icons.image_outlined,
title: context.l10n.cacheCoverImage,
subtitle: _buildSubtitle(
context.l10n.cacheCoverImageDesc,
overview.coverStats.fileCount > 0 &&
overview.coverStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
_formatBytes(overview.coverStats.totalSizeBytes),
overview.coverStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_cover_cache',
() => _confirmAndRunAction(
actionKey: 'clear_cover_cache',
targetLabel: context.l10n.cacheCoverImage,
action: _clearCoverCache,
),
),
),
SettingsItem(
icon: Icons.library_music_outlined,
title: context.l10n.cacheLibraryCover,
subtitle: _buildSubtitle(
context.l10n.cacheLibraryCoverDesc,
overview.libraryCoverStats.fileCount > 0 &&
overview.libraryCoverStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
_formatBytes(
overview.libraryCoverStats.totalSizeBytes,
),
overview.libraryCoverStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_library_cover_cache',
() => _confirmAndRunAction(
actionKey: 'clear_library_cover_cache',
targetLabel: context.l10n.cacheLibraryCover,
action: _clearLibraryCoverCache,
),
),
),
SettingsItem(
icon: Icons.explore_outlined,
title: context.l10n.cacheExploreFeed,
subtitle: _buildSubtitle(
context.l10n.cacheExploreFeedDesc,
overview.hasExploreCache
? context.l10n.cacheSizeOnly(
_formatBytes(overview.exploreCacheBytes),
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_explore_cache',
() => _confirmAndRunAction(
actionKey: 'clear_explore_cache',
targetLabel: context.l10n.cacheExploreFeed,
action: _clearExploreCache,
),
),
),
SettingsItem(
icon: Icons.memory_outlined,
title: context.l10n.cacheTrackLookup,
subtitle: _buildSubtitle(
context.l10n.cacheTrackLookupDesc,
overview.trackCacheEntries > 0
? context.l10n.cacheEntries(overview.trackCacheEntries)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_track_cache',
() => _confirmAndRunAction(
actionKey: 'clear_track_cache',
targetLabel: context.l10n.cacheTrackLookup,
action: _clearTrackCache,
),
),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.cacheSectionMaintenance,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.cacheCleanupUnused,
subtitle: '${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
trailing: _buildClearTrailing(
'cleanup_unused',
_cleanupUnusedData,
),
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
],
),
);
}
}
class _CacheOverview {
final String appCachePath;
final _DirectoryStats appCacheStats;
final String? tempPath;
final _DirectoryStats? tempStats;
final bool tempIsSameAsAppCache;
final CacheStats coverStats;
final _DirectoryStats libraryCoverStats;
final int exploreCacheBytes;
final bool hasExploreCache;
final int trackCacheEntries;
const _CacheOverview({
required this.appCachePath,
required this.appCacheStats,
this.tempPath,
this.tempStats,
required this.tempIsSameAsAppCache,
required this.coverStats,
required this.libraryCoverStats,
required this.exploreCacheBytes,
required this.hasExploreCache,
required this.trackCacheEntries,
});
int get totalKnownDiskCacheBytes {
return appCacheStats.totalSizeBytes +
(tempStats?.totalSizeBytes ?? 0) +
coverStats.totalSizeBytes +
libraryCoverStats.totalSizeBytes +
exploreCacheBytes;
}
}
class _DirectoryStats {
final int fileCount;
final int totalSizeBytes;
const _DirectoryStats({
required this.fileCount,
required this.totalSizeBytes,
});
}
+97 -78
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/donate_icons.dart'; import 'package:spotiflac_android/widgets/donate_icons.dart';
class DonatePage extends StatelessWidget { class DonatePage extends StatelessWidget {
@@ -9,7 +10,7 @@ class DonatePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
@@ -54,47 +55,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 +63,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,
), ),
], ],
), ),
), ),
), ),
], ],
), ),
), ),
@@ -214,6 +200,8 @@ class _RecentDonorsCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_DonorTile(name: 'J', colorScheme: colorScheme),
_DonorTile(name: 'Julian', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme), _DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile( _DonorTile(
name: '283Fabio', name: '283Fabio',
@@ -417,3 +405,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,
),
),
),
],
);
}
}
@@ -9,6 +9,8 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerStatefulWidget { class DownloadSettingsPage extends ConsumerStatefulWidget {
@@ -20,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
} }
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> { class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon']; static const _builtInServices = ['tidal', 'qobuz'];
int _androidSdkVersion = 0; int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false; bool _hasAllFilesAccess = false;
@@ -93,7 +95,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService); final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal'; final isTidalService = settings.defaultService == 'tidal';
@@ -246,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Select Tidal, Qobuz, or Amazon above to configure quality', 'Select Tidal or Qobuz above to configure quality',
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -346,7 +348,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ref, ref,
settings.folderOrganization, settings.folderOrganization,
), ),
showDivider: false, ),
SettingsSwitchItem(
icon: Icons.person_search_outlined,
title: context.l10n.downloadUseAlbumArtistForFolders,
subtitle: settings.useAlbumArtistForFolders
? context
.l10n
.downloadUseAlbumArtistForFoldersAlbumSubtitle
: context
.l10n
.downloadUseAlbumArtistForFoldersTrackSubtitle,
value: settings.useAlbumArtistForFolders,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
showDivider: false,
), ),
], ],
), ),
@@ -901,17 +918,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
// Note: iOS requires folder to have at least one file to be selectable // Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) { if (result != null) {
// iOS: Check if user selected iCloud Drive (not accessible by Go backend) // iOS: Validate the selected path is writable (not iCloud or container root)
if (Platform.isIOS) { if (Platform.isIOS) {
final isICloudPath = final validation = validateIosPath(result);
result.contains('Mobile Documents') || if (!validation.isValid) {
result.contains('CloudDocs') ||
result.contains('com~apple~CloudDocs');
if (isICloudPath) {
if (ctx.mounted) { if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.setupIcloudNotSupported), content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
@@ -1340,7 +1354,6 @@ class _ServiceSelector extends ConsumerWidget {
final isExtensionService = ![ final isExtensionService = ![
'tidal', 'tidal',
'qobuz', 'qobuz',
'amazon',
].contains(currentService); ].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService) ? extensionProviders.any((e) => e.id == currentService)
@@ -1367,15 +1380,6 @@ class _ServiceSelector extends ConsumerWidget {
isSelected: effectiveService == 'qobuz', isSelected: effectiveService == 'qobuz',
onTap: () => onChanged('qobuz'), onTap: () => onChanged('qobuz'),
), ),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
isDisabled: true,
disabledReason: 'Coming soon',
onTap: () {},
),
], ],
), ),
if (extensionProviders.isNotEmpty) ...[ if (extensionProviders.isNotEmpty) ...[
@@ -1411,15 +1415,11 @@ class _ServiceChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
final bool isDisabled;
final String? disabledReason;
const _ServiceChip({ const _ServiceChip({
required this.icon, required this.icon,
required this.label, required this.label,
required this.isSelected, required this.isSelected,
required this.onTap, required this.onTap,
this.isDisabled = false,
this.disabledReason,
}); });
@override @override
@@ -1434,66 +1434,39 @@ class _ServiceChip extends StatelessWidget {
) )
: colorScheme.surfaceContainerHigh; : colorScheme.surfaceContainerHigh;
final disabledColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.02),
colorScheme.surface,
)
: colorScheme.surfaceContainerLow;
return Expanded( return Expanded(
child: Tooltip( child: Material(
message: isDisabled && disabledReason != null ? disabledReason! : '', color: isSelected
child: Material( ? colorScheme.primaryContainer
color: isDisabled : unselectedColor,
? disabledColor borderRadius: BorderRadius.circular(12),
: isSelected child: InkWell(
? colorScheme.primaryContainer onTap: onTap,
: unselectedColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: InkWell( child: Padding(
onTap: isDisabled ? null : onTap, padding: const EdgeInsets.symmetric(vertical: 14),
borderRadius: BorderRadius.circular(12), child: Column(
child: Padding( children: [
padding: const EdgeInsets.symmetric(vertical: 14), Icon(
child: Column( icon,
children: [ color: isSelected
Icon( ? colorScheme.onPrimaryContainer
icon, : colorScheme.onSurfaceVariant,
color: isDisabled ),
? colorScheme.onSurface.withValues(alpha: 0.38) const SizedBox(height: 6),
: isSelected Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer ? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 6), ),
Text( ],
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected && !isDisabled
? FontWeight.w600
: FontWeight.normal,
color: isDisabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
if (isDisabled && disabledReason != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
disabledReason!,
style: TextStyle(
fontSize: 9,
color: colorScheme.onSurface.withValues(alpha: 0.38),
),
),
),
],
),
), ),
), ),
), ),
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget { class ExtensionDetailPage extends ConsumerStatefulWidget {
@@ -55,7 +56,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
); );
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final hasError = extension.status == 'error'; final hasError = extension.status == 'error';
return PopScope( return PopScope(
+2 -1
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart'; import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionsPage extends ConsumerStatefulWidget { class ExtensionsPage extends ConsumerStatefulWidget {
@@ -51,7 +52,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true, // Always allow back gesture
+57 -23
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget { class LibrarySettingsPage extends ConsumerStatefulWidget {
@@ -30,7 +31,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
// -> /storage/emulated/0/Music // -> /storage/emulated/0/Music
try { try {
final uri = Uri.parse(path); final uri = Uri.parse(path);
final treePath = uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic" final treePath =
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final decoded = Uri.decodeComponent(treePath); final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) { if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}'; return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
@@ -156,10 +158,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return; return;
} }
await ref.read(localLibraryProvider.notifier).startScan( await ref
libraryPath, .read(localLibraryProvider.notifier)
forceFullScan: forceFullScan, .startScan(libraryPath, forceFullScan: forceFullScan);
);
} }
Future<void> _cancelScan() async { Future<void> _cancelScan() async {
@@ -216,7 +217,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final libraryState = ref.watch(localLibraryProvider); final libraryState = ref.watch(localLibraryProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
@@ -260,6 +261,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: _LibraryHeroCard( child: _LibraryHeroCard(
itemCount: libraryState.items.length, itemCount: libraryState.items.length,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning, isScanning: libraryState.isScanning,
scanProgress: libraryState.scanProgress, scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile, scanCurrentFile: libraryState.scanCurrentFile,
@@ -331,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6), color: colorScheme.tertiaryContainer.withValues(
alpha: 0.6,
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -347,17 +351,20 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
children: [ children: [
Text( Text(
'Scan cancelled', 'Scan cancelled',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
fontWeight: FontWeight.w600, ?.copyWith(
color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600,
), color: colorScheme.onTertiaryContainer,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'You can retry the scan when ready.', 'You can retry the scan when ready.',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8), ?.copyWith(
), color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.8),
),
), ),
], ],
), ),
@@ -493,6 +500,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
class _LibraryHeroCard extends StatelessWidget { class _LibraryHeroCard extends StatelessWidget {
final int itemCount; final int itemCount;
final int excludedDownloadedCount;
final bool isScanning; final bool isScanning;
final double scanProgress; final double scanProgress;
final String? scanCurrentFile; final String? scanCurrentFile;
@@ -502,6 +510,7 @@ class _LibraryHeroCard extends StatelessWidget {
const _LibraryHeroCard({ const _LibraryHeroCard({
required this.itemCount, required this.itemCount,
required this.excludedDownloadedCount,
required this.isScanning, required this.isScanning,
required this.scanProgress, required this.scanProgress,
this.scanCurrentFile, this.scanCurrentFile,
@@ -527,10 +536,13 @@ class _LibraryHeroCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final displayCount = isScanning
? scannedFiles
: itemCount + excludedDownloadedCount;
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 220, constraints: const BoxConstraints(minHeight: 220),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
@@ -626,12 +638,12 @@ class _LibraryHeroCard extends StatelessWidget {
), ),
], ],
), ),
const Spacer(), const SizedBox(height: 16),
FittedBox( FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
isScanning ? scannedFiles.toString() : itemCount.toString(), displayCount.toString(),
style: TextStyle( style: TextStyle(
fontSize: 48, fontSize: 48,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -644,17 +656,35 @@ class _LibraryHeroCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
isScanning isScanning
? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim() ? context.l10n
.libraryTracksCount(scannedFiles)
.replaceAll(scannedFiles.toString(), '')
.trim()
: context.l10n : context.l10n
.libraryTracksCount(itemCount) .libraryTracksCount(displayCount)
.replaceAll(itemCount.toString(), '') .replaceAll(displayCount.toString(), '')
.trim(), .trim(),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
if (!isScanning && excludedDownloadedCount > 0) ...[
const SizedBox(height: 4),
Text(
'$excludedDownloadedCount from Downloads history '
'(excluded from list)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.8,
),
),
),
],
if (isScanning && scanCurrentFile != null) ...[ if (isScanning && scanCurrentFile != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
LinearProgressIndicator( LinearProgressIndicator(
@@ -670,7 +700,9 @@ class _LibraryHeroCard extends StatelessWidget {
Icon( Icon(
Icons.history, Icons.history,
size: 14, size: 14,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
@@ -679,7 +711,9 @@ class _LibraryHeroCard extends StatelessWidget {
), ),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
), ),
), ),
], ],
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -126,7 +127,7 @@ class _LogScreenState extends State<LogScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final logs = _filteredLogs; final logs = _filteredLogs;
return PopScope( return PopScope(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget { class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
const MetadataProviderPriorityPage({super.key}); const MetadataProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: !_hasChanges, canPop: !_hasChanges,
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class OptionsSettingsPage extends ConsumerWidget { class OptionsSettingsPage extends ConsumerWidget {
@@ -16,7 +17,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final extensionState = ref.watch(extensionProvider); final extensionState = ref.watch(extensionProvider);
final hasExtensions = extensionState.extensions.isNotEmpty; final hasExtensions = extensionState.extensions.isNotEmpty;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true, // Always allow back gesture
@@ -958,6 +959,27 @@ class _MetadataSourceSelector extends ConsumerWidget {
], ],
), ),
], ],
if (currentSource == 'spotify' && !hasExtensionSearch) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 16,
color: colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.optionsSpotifyDeprecationWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
], ],
), ),
); );
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget { class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key}); const ProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: !_hasChanges, canPop: !_hasChanges,
+20 -7
View File
@@ -8,8 +8,10 @@ import 'package:spotiflac_android/screens/settings/extensions_page.dart';
import 'package:spotiflac_android/screens/settings/library_settings_page.dart'; import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/cache_management_page.dart';
import 'package:spotiflac_android/screens/settings/donate_page.dart'; import 'package:spotiflac_android/screens/settings/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget { class SettingsTab extends ConsumerWidget {
@@ -18,7 +20,7 @@ class SettingsTab extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -73,19 +75,29 @@ class SettingsTab extends ConsumerWidget {
icon: Icons.download_outlined, icon: Icons.download_outlined,
title: l10n.settingsDownload, title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle, subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()), onTap: () =>
_navigateTo(context, const DownloadSettingsPage()),
), ),
SettingsItem( SettingsItem(
icon: Icons.library_music_outlined, icon: Icons.library_music_outlined,
title: l10n.settingsLocalLibrary, title: l10n.settingsLocalLibrary,
subtitle: l10n.settingsLocalLibrarySubtitle, subtitle: l10n.settingsLocalLibrarySubtitle,
onTap: () => _navigateTo(context, const LibrarySettingsPage()), onTap: () =>
_navigateTo(context, const LibrarySettingsPage()),
),
SettingsItem(
icon: Icons.storage_outlined,
title: l10n.settingsCache,
subtitle: l10n.settingsCacheSubtitle,
onTap: () =>
_navigateTo(context, const CacheManagementPage()),
), ),
SettingsItem( SettingsItem(
icon: Icons.tune_outlined, icon: Icons.tune_outlined,
title: l10n.settingsOptions, title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle, subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()), onTap: () =>
_navigateTo(context, const OptionsSettingsPage()),
), ),
SettingsItem( SettingsItem(
icon: Icons.extension_outlined, icon: Icons.extension_outlined,
@@ -146,9 +158,10 @@ class SettingsTab extends ConsumerWidget {
const begin = Offset(1.0, 0.0); const begin = Offset(1.0, 0.0);
const end = Offset.zero; const end = Offset.zero;
const curve = Curves.easeInOut; const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain( var tween = Tween(
CurveTween(curve: curve), begin: begin,
); end: end,
).chain(CurveTween(curve: curve));
return SlideTransition( return SlideTransition(
position: animation.drive(tween), position: animation.drive(tween),
child: child, child: child,
+132 -69
View File
@@ -9,6 +9,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
class SetupScreen extends ConsumerStatefulWidget { class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key}); const SetupScreen({super.key});
@@ -248,7 +249,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.setupUseDefaultFolder), title: Text(context.l10n.setupUseDefaultFolder),
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'), content: Text(
'${context.l10n.setupNoFolderSelected}\n\n$defaultDir',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
@@ -320,6 +323,22 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Navigator.pop(ctx); Navigator.pop(ctx);
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) { if (result != null) {
// iOS: Validate the selected path is writable
if (Platform.isIOS) {
final validation = validateIosPath(result);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(validation.errorReason ?? 'Invalid folder selected'),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
}
setState(() => _selectedDirectory = result); setState(() => _selectedDirectory = result);
} }
}, },
@@ -576,37 +595,60 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} }
Widget _buildWelcomeStep(ColorScheme colorScheme) { Widget _buildWelcomeStep(ColorScheme colorScheme) {
return Padding( return LayoutBuilder(
padding: const EdgeInsets.all(24), builder: (context, constraints) {
child: Column( final shortestSide = MediaQuery.sizeOf(context).shortestSide;
mainAxisAlignment: MainAxisAlignment.center, final textScale = MediaQuery.textScalerOf(
children: [ context,
Image.asset( ).scale(1.0).clamp(1.0, 1.4);
'assets/images/logo-transparant.png', final logoSize = (shortestSide * 0.24).clamp(80.0, 104.0);
width: 104, final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
height: 104, final subtitleGap = (shortestSide * 0.04).clamp(8.0, 16.0);
color: colorScheme.primary, final minContentHeight = constraints.maxHeight > 48
fit: BoxFit.contain, ? constraints.maxHeight - 48
), : 0.0;
const SizedBox(height: 32),
Text( return SingleChildScrollView(
context.l10n.appName, padding: const EdgeInsets.all(24),
style: Theme.of(context).textTheme.displaySmall?.copyWith( child: ConstrainedBox(
fontWeight: FontWeight.bold, constraints: BoxConstraints(minHeight: minContentHeight),
color: colorScheme.onSurface, child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo-transparant.png',
width: logoSize,
height: logoSize,
color: colorScheme.primary,
fit: BoxFit.contain,
),
SizedBox(height: titleGap),
Text(
context.l10n.appName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
fontSize:
(Theme.of(context).textTheme.displaySmall?.fontSize ??
36) *
(1 + ((textScale - 1) * 0.18)),
),
),
SizedBox(height: subtitleGap),
Text(
context.l10n.setupDownloadInFlac,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
), ),
), ),
const SizedBox(height: 16), );
Text( },
context.l10n.setupDownloadInFlac,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
); );
} }
@@ -833,41 +875,58 @@ class _StepLayout extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Padding( return LayoutBuilder(
padding: const EdgeInsets.all(24), builder: (context, constraints) {
child: Column( final shortestSide = MediaQuery.sizeOf(context).shortestSide;
mainAxisAlignment: MainAxisAlignment.center, final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
children: [ final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
Container( final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
padding: const EdgeInsets.all(24), final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0);
decoration: BoxDecoration( final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0);
color: colorScheme.surfaceContainerHighest, final minContentHeight = constraints.maxHeight > 48
shape: BoxShape.circle, ? constraints.maxHeight - 48
: 0.0;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minContentHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(icon, size: iconSize, color: colorScheme.primary),
),
SizedBox(height: titleGap),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
SizedBox(height: descriptionGap),
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
textAlign: TextAlign.center,
),
SizedBox(height: actionGap),
child,
],
), ),
child: Icon(icon, size: 48, color: colorScheme.primary),
), ),
const SizedBox(height: 32), );
Text( },
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
child,
],
),
); );
} }
} }
@@ -881,21 +940,25 @@ class _SuccessCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer, color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer), Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Expanded(
text, child: Text(
style: TextStyle( text,
fontWeight: FontWeight.bold, style: TextStyle(
color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
+2 -1
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget { class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key}); const StoreTab({super.key});
@@ -44,7 +45,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(storeProvider); final state = ref.watch(storeProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: RefreshIndicator( body: RefreshIndicator(
+882 -15
View File
@@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
class TrackMetadataScreen extends ConsumerStatefulWidget { class TrackMetadataScreen extends ConsumerStatefulWidget {
@@ -35,6 +36,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
bool _isEmbedding = false; // Track embed operation in progress bool _isEmbedding = false; // Track embed operation in progress
bool _isInstrumental = false; // Track if detected as instrumental bool _isInstrumental = false; // Track if detected as instrumental
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern = static final RegExp _lrcTimestampPattern =
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
@@ -117,17 +119,35 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
LocalLibraryItem? get _localLibraryItem => widget.localItem; LocalLibraryItem? get _localLibraryItem => widget.localItem;
String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
String get trackName => _isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName; String get trackName => _editedMetadata?['title']?.toString() ?? (_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName);
String get artistName => _isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName; String get artistName => _editedMetadata?['artist']?.toString() ?? (_isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName);
String get albumName => _isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName; String get albumName => _editedMetadata?['album']?.toString() ?? (_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName);
String? get albumArtist => _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist); String? get albumArtist {
int? get trackNumber => _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber; final edited = _editedMetadata?['album_artist']?.toString();
int? get discNumber => _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber; if (edited != null && edited.isNotEmpty) return edited;
String? get releaseDate => _isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate; return _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist);
String? get isrc => _isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc; }
String? get genre => _isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre; int? get trackNumber {
String? get label => _isLocalItem ? null : _downloadItem!.label; final edited = _editedMetadata?['track_number'];
String? get copyright => _isLocalItem ? null : _downloadItem!.copyright; if (edited != null) {
final v = int.tryParse(edited.toString());
if (v != null && v > 0) return v;
}
return _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber;
}
int? get discNumber {
final edited = _editedMetadata?['disc_number'];
if (edited != null) {
final v = int.tryParse(edited.toString());
if (v != null && v > 0) return v;
}
return _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber;
}
String? get releaseDate => _editedMetadata?['date']?.toString() ?? (_isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate);
String? get isrc => _editedMetadata?['isrc']?.toString() ?? (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc);
String? get genre => _editedMetadata?['genre']?.toString() ?? (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre);
String? get label => _editedMetadata?['label']?.toString() ?? (_isLocalItem ? null : _downloadItem!.label);
String? get copyright => _editedMetadata?['copyright']?.toString() ?? (_isLocalItem ? null : _downloadItem!.copyright);
int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration; int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
@@ -1083,6 +1103,380 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
String _buildSaveBaseName() {
final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
return '$artist - $track';
}
String _getFileDirectory() {
if (isContentUri(cleanFilePath)) {
// SAF URIs don't have a filesystem parent directory
return '';
}
final file = File(cleanFilePath);
return file.parent.path;
}
bool get _isSafFile => isContentUri(cleanFilePath);
Future<void> _saveCoverArt() async {
try {
final baseName = _buildSaveBaseName();
if (_isSafFile) {
// SAF file: save to temp, then copy to SAF tree
final tempDir = await Directory.systemTemp.createTemp('cover_');
final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.jpg';
Map<String, dynamic> result;
if (_coverUrl != null && _coverUrl!.isNotEmpty) {
result = await PlatformBridge.downloadCoverToFile(
_coverUrl!,
tempOutput,
maxQuality: true,
);
} else if (_fileExists) {
result = await PlatformBridge.extractCoverToFile(
cleanFilePath,
tempOutput,
);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverNoSource)),
);
}
return;
}
if (result['error'] != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
);
}
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
return;
}
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: '$baseName.jpg',
mimeType: 'image/jpeg',
srcPath: tempOutput,
);
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
if (mounted) {
if (safUri != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write to storage'))),
);
}
}
} else {
// No SAF tree info, keep in temp
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('No storage access'))),
);
}
}
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
Map<String, dynamic> result;
if (_coverUrl != null && _coverUrl!.isNotEmpty) {
result = await PlatformBridge.downloadCoverToFile(
_coverUrl!,
outputPath,
maxQuality: true,
);
} else if (_fileExists) {
result = await PlatformBridge.extractCoverToFile(
cleanFilePath,
outputPath,
);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverNoSource)),
);
}
return;
}
if (mounted) {
if (result['error'] != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
);
}
}
}
Future<void> _saveLyrics() async {
try {
final baseName = _buildSaveBaseName();
final durationMs = (duration ?? 0) * 1000;
if (_isSafFile) {
// SAF file: save to temp, then copy to SAF tree
final tempDir = await Directory.systemTemp.createTemp('lyrics_');
final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.lrc';
final result = await PlatformBridge.fetchAndSaveLyrics(
trackName: trackName,
artistName: artistName,
spotifyId: _spotifyId ?? '',
durationMs: durationMs,
outputPath: tempOutput,
);
if (result['error'] != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
);
}
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
return;
}
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: '$baseName.lrc',
mimeType: 'text/plain',
srcPath: tempOutput,
);
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
if (mounted) {
if (safUri != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write to storage'))),
);
}
}
} else {
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('No storage access'))),
);
}
}
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
final result = await PlatformBridge.fetchAndSaveLyrics(
trackName: trackName,
artistName: artistName,
spotifyId: _spotifyId ?? '',
durationMs: durationMs,
outputPath: outputPath,
);
if (mounted) {
if (result['error'] != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
);
}
}
}
Future<void> _reEnrichMetadata() async {
if (!_fileExists) return;
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
);
final durationMs = (duration ?? 0) * 1000;
final request = <String, dynamic>{
'file_path': cleanFilePath,
'cover_url': _coverUrl ?? '',
'max_quality': true,
'embed_lyrics': true,
'spotify_id': _spotifyId ?? '',
'track_name': trackName,
'artist_name': artistName,
'album_name': albumName,
'album_artist': albumArtist ?? artistName,
'track_number': trackNumber ?? 0,
'disc_number': discNumber ?? 0,
'release_date': releaseDate ?? '',
'isrc': isrc ?? '',
'genre': genre ?? '',
'label': label ?? '',
'copyright': copyright ?? '',
'duration_ms': durationMs,
'search_online': true,
};
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
// Update local UI state with enriched metadata from online search
final enriched = result['enriched_metadata'] as Map<String, dynamic>?;
if (enriched != null && mounted) {
setState(() {
_editedMetadata = {
'title': enriched['track_name'] ?? trackName,
'artist': enriched['artist_name'] ?? artistName,
'album': enriched['album_name'] ?? albumName,
'album_artist': enriched['album_artist'] ?? albumArtist,
'date': enriched['release_date'] ?? releaseDate,
'track_number': enriched['track_number'] ?? trackNumber,
'disc_number': enriched['disc_number'] ?? discNumber,
'isrc': enriched['isrc'] ?? isrc,
'genre': enriched['genre'] ?? genre,
'label': enriched['label'] ?? label,
'copyright': enriched['copyright'] ?? copyright,
};
});
}
if (method == 'native') {
// FLAC - handled natively by Go (SAF write-back handled in Kotlin)
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
);
}
} else if (method == 'ffmpeg') {
// MP3/Opus - need FFmpeg from Dart side
// For SAF files, Kotlin returns temp_path + saf_uri
final tempPath = result['temp_path'] as String?;
final safUri = result['saf_uri'] as String?;
final ffmpegTarget = tempPath ?? cleanFilePath;
final coverPath = result['cover_path'] as String?;
final metadata = (result['metadata'] as Map<String, dynamic>?)
?.map((k, v) => MapEntry(k, v.toString()));
final lower = cleanFilePath.toLowerCase();
String? ffmpegResult;
if (lower.endsWith('.mp3')) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: ffmpegTarget,
coverPath: coverPath,
metadata: metadata,
);
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: coverPath,
metadata: metadata,
);
}
// For SAF files, copy processed temp file back
if (ffmpegResult != null && tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write back to storage'))),
);
// Cleanup temp files
if (coverPath != null && coverPath.isNotEmpty) {
try { await File(coverPath).delete(); } catch (_) {}
}
if (tempPath.isNotEmpty) {
try { await File(tempPath).delete(); } catch (_) {}
}
return;
}
}
// Cleanup temp files
if (tempPath != null && tempPath.isNotEmpty) {
try { await File(tempPath).delete(); } catch (_) {}
}
if (mounted) {
if (ffmpegResult != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)),
);
}
}
// Cleanup temp cover from Go backend
if (coverPath != null && coverPath.isNotEmpty) {
try { await File(coverPath).delete(); } catch (_) {}
}
} else {
if (mounted) {
final error = result['error']?.toString() ?? 'Unknown error';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(error))),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
);
}
}
}
String _cleanLrcForDisplay(String lrc) { String _cleanLrcForDisplay(String lrc) {
final lines = lrc.split('\n'); final lines = lrc.split('\n');
final cleanLines = <String>[]; final cleanLines = <String>[];
@@ -1148,8 +1542,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
builder: (context) => SafeArea( builder: (context) => SafeArea(
child: Column( child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -1170,6 +1569,46 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_copyToClipboard(context, cleanFilePath); _copyToClipboard(context, cleanFilePath);
}, },
), ),
if (_fileExists)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(context.l10n.trackEditMetadata),
onTap: () {
Navigator.pop(context);
_showEditMetadataSheet(context, ref, colorScheme);
},
),
if (!_isLocalItem && (_coverUrl != null || _fileExists))
ListTile(
leading: const Icon(Icons.image_outlined),
title: Text(context.l10n.trackSaveCoverArt),
subtitle: Text(context.l10n.trackSaveCoverArtSubtitle),
onTap: () {
Navigator.pop(context);
_saveCoverArt();
},
),
if (!_isLocalItem)
ListTile(
leading: const Icon(Icons.lyrics_outlined),
title: Text(context.l10n.trackSaveLyrics),
subtitle: Text(context.l10n.trackSaveLyricsSubtitle),
onTap: () {
Navigator.pop(context);
_saveLyrics();
},
),
if (_fileExists)
ListTile(
leading: const Icon(Icons.travel_explore),
title: Text(context.l10n.trackReEnrich),
subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle),
onTap: () {
Navigator.pop(context);
_reEnrichMetadata();
},
),
const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.share), leading: const Icon(Icons.share),
title: Text(context.l10n.trackMetadataShare), title: Text(context.l10n.trackMetadataShare),
@@ -1189,10 +1628,75 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
), ),
),
), ),
); );
} }
void _showEditMetadataSheet(BuildContext context, WidgetRef ref, ColorScheme colorScheme) async {
// Read current metadata from file, fall back to item data on failure
Map<String, dynamic>? fileMetadata;
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
if (result['error'] == null) {
fileMetadata = result;
}
} catch (e) {
debugPrint('readFileMetadata failed, using item data: $e');
}
// Build initial values map prefer file metadata, fall back to item data
String val(String key, String? fallback) {
final v = fileMetadata?[key]?.toString();
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
}
final initialValues = <String, String>{
'title': val('title', trackName),
'artist': val('artist', artistName),
'album': val('album', albumName),
'album_artist': val('album_artist', albumArtist),
'date': val('date', releaseDate),
'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '').toString(),
'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '').toString(),
'genre': val('genre', genre),
'isrc': val('isrc', isrc),
'label': val('label', label),
'copyright': val('copyright', copyright),
'composer': fileMetadata?['composer']?.toString() ?? '',
'comment': fileMetadata?['comment']?.toString() ?? '',
};
if (!context.mounted) return;
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => _EditMetadataSheet(
colorScheme: colorScheme,
initialValues: initialValues,
filePath: cleanFilePath,
),
);
if (saved == true && mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Metadata saved successfully')),
);
// Re-read metadata from file to refresh the display
try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
setState(() => _editedMetadata = refreshed);
} catch (_) {
setState(() {});
}
}
}
void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
showDialog( showDialog(
context: context, context: context,
@@ -1324,17 +1828,380 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Color _getServiceColor(String service, ColorScheme colorScheme) { Color _getServiceColor(String service, ColorScheme colorScheme) {
switch (service.toLowerCase()) { switch (service.toLowerCase()) {
case 'tidal': case 'tidal':
return const Color(0xFF0077B5); // Tidal blue (darker, more readable) return const Color(0xFF0077B5);
case 'qobuz': case 'qobuz':
return const Color(0xFF0052CC); // Qobuz blue return const Color(0xFF0052CC);
case 'amazon': case 'amazon':
return const Color(0xFFFF9900); // Amazon orange return const Color(0xFFFF9900);
default: default:
return colorScheme.primary; return colorScheme.primary;
} }
} }
} }
class _EditMetadataSheet extends StatefulWidget {
final ColorScheme colorScheme;
final Map<String, String> initialValues;
final String filePath;
const _EditMetadataSheet({
required this.colorScheme,
required this.initialValues,
required this.filePath,
});
@override
State<_EditMetadataSheet> createState() => _EditMetadataSheetState();
}
class _EditMetadataSheetState extends State<_EditMetadataSheet> {
bool _saving = false;
bool _showAdvanced = false;
late final TextEditingController _titleCtrl;
late final TextEditingController _artistCtrl;
late final TextEditingController _albumCtrl;
late final TextEditingController _albumArtistCtrl;
late final TextEditingController _dateCtrl;
late final TextEditingController _trackNumCtrl;
late final TextEditingController _discNumCtrl;
late final TextEditingController _genreCtrl;
late final TextEditingController _isrcCtrl;
late final TextEditingController _labelCtrl;
late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl;
late final TextEditingController _commentCtrl;
@override
void initState() {
super.initState();
final v = widget.initialValues;
_titleCtrl = TextEditingController(text: v['title'] ?? '');
_artistCtrl = TextEditingController(text: v['artist'] ?? '');
_albumCtrl = TextEditingController(text: v['album'] ?? '');
_albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? '');
_dateCtrl = TextEditingController(text: v['date'] ?? '');
_trackNumCtrl = TextEditingController(text: v['track_number'] ?? '');
_discNumCtrl = TextEditingController(text: v['disc_number'] ?? '');
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
_labelCtrl = TextEditingController(text: v['label'] ?? '');
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
_commentCtrl = TextEditingController(text: v['comment'] ?? '');
}
@override
void dispose() {
_titleCtrl.dispose();
_artistCtrl.dispose();
_albumCtrl.dispose();
_albumArtistCtrl.dispose();
_dateCtrl.dispose();
_trackNumCtrl.dispose();
_discNumCtrl.dispose();
_genreCtrl.dispose();
_isrcCtrl.dispose();
_labelCtrl.dispose();
_copyrightCtrl.dispose();
_composerCtrl.dispose();
_commentCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
final metadata = <String, String>{
'title': _titleCtrl.text,
'artist': _artistCtrl.text,
'album': _albumCtrl.text,
'album_artist': _albumArtistCtrl.text,
'date': _dateCtrl.text,
'track_number': _trackNumCtrl.text,
'disc_number': _discNumCtrl.text,
'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text,
'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
'comment': _commentCtrl.text,
};
try {
final result = await PlatformBridge.editFileMetadata(
widget.filePath,
metadata,
);
if (result['error'] != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${result['error']}')),
);
}
setState(() => _saving = false);
return;
}
final method = result['method'] as String?;
if (method == 'ffmpeg') {
// MP3/Opus: use FFmpeg to write metadata
// For SAF files, Kotlin returns temp_path + saf_uri
final tempPath = result['temp_path'] as String?;
final safUri = result['saf_uri'] as String?;
final ffmpegTarget = tempPath ?? widget.filePath;
final lower = widget.filePath.toLowerCase();
final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final vorbisMap = <String, String>{};
if (metadata['title']?.isNotEmpty == true) vorbisMap['TITLE'] = metadata['title']!;
if (metadata['artist']?.isNotEmpty == true) vorbisMap['ARTIST'] = metadata['artist']!;
if (metadata['album']?.isNotEmpty == true) vorbisMap['ALBUM'] = metadata['album']!;
if (metadata['album_artist']?.isNotEmpty == true) vorbisMap['ALBUMARTIST'] = metadata['album_artist']!;
if (metadata['date']?.isNotEmpty == true) vorbisMap['DATE'] = metadata['date']!;
if (metadata['track_number']?.isNotEmpty == true && metadata['track_number'] != '0') {
vorbisMap['TRACKNUMBER'] = metadata['track_number']!;
}
if (metadata['disc_number']?.isNotEmpty == true && metadata['disc_number'] != '0') {
vorbisMap['DISCNUMBER'] = metadata['disc_number']!;
}
if (metadata['genre']?.isNotEmpty == true) vorbisMap['GENRE'] = metadata['genre']!;
if (metadata['isrc']?.isNotEmpty == true) vorbisMap['ISRC'] = metadata['isrc']!;
if (metadata['label']?.isNotEmpty == true) vorbisMap['ORGANIZATION'] = metadata['label']!;
if (metadata['copyright']?.isNotEmpty == true) vorbisMap['COPYRIGHT'] = metadata['copyright']!;
if (metadata['composer']?.isNotEmpty == true) vorbisMap['COMPOSER'] = metadata['composer']!;
if (metadata['comment']?.isNotEmpty == true) vorbisMap['COMMENT'] = metadata['comment']!;
// Extract existing cover art before re-embedding metadata
String? existingCoverPath;
try {
final tempDir = await Directory.systemTemp.createTemp('cover_');
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
final coverResult = await PlatformBridge.extractCoverToFile(ffmpegTarget, coverOutput);
if (coverResult['error'] == null) {
existingCoverPath = coverOutput;
}
} catch (_) {
// No cover to preserve, continue without
}
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
);
}
// Cleanup temp cover
if (existingCoverPath != null) {
try { await File(existingCoverPath).delete(); } catch (_) {}
}
if (ffmpegResult == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to save metadata via FFmpeg')),
);
}
setState(() => _saving = false);
return;
}
// For SAF files, copy the processed temp file back
if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to write metadata back to storage')),
);
setState(() => _saving = false);
return;
}
}
}
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save metadata: $e')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final cs = widget.colorScheme;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
// Handle bar
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Title row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(
child: Text(
'Edit Metadata',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (_saving)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
),
const SizedBox(height: 12),
// Fields
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24),
children: [
_field('Title', _titleCtrl),
_field('Artist', _artistCtrl),
_field('Album', _albumCtrl),
_field('Album Artist', _albumArtistCtrl),
_field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'),
Row(
children: [
Expanded(child: _field('Track #', _trackNumCtrl, keyboard: TextInputType.number)),
const SizedBox(width: 12),
Expanded(child: _field('Disc #', _discNumCtrl, keyboard: TextInputType.number)),
],
),
_field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl),
// Advanced fields toggle
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell(
onTap: () => setState(() => _showAdvanced = !_showAdvanced),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
_showAdvanced ? Icons.expand_less : Icons.expand_more,
size: 20,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Advanced',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
),
),
if (_showAdvanced) ...[
_field('Label', _labelCtrl),
_field('Copyright', _copyrightCtrl),
_field('Composer', _composerCtrl),
_field('Comment', _commentCtrl, maxLines: 3),
],
const SizedBox(height: 24),
],
),
),
],
),
),
);
}
Widget _field(
String label,
TextEditingController controller, {
String? hint,
TextInputType? keyboard,
int maxLines = 1,
}) {
final cs = widget.colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
keyboardType: keyboard,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
hintText: hint,
filled: true,
fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
);
}
}
class _MetadataItem { class _MetadataItem {
final String label; final String label;
final String value; final String value;
+161 -105
View File
@@ -16,6 +16,26 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
int _currentPage = 0; int _currentPage = 0;
static const int _totalPages = 6; static const int _totalPages = 6;
double _responsiveScale({
required BuildContext context,
double min = 0.82,
double max = 1.08,
double baseShortestSide = 390,
}) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = shortestSide / baseShortestSide;
if (scale < min) return min;
if (scale > max) return max;
return scale;
}
double _effectiveTextScale(BuildContext context) {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
@@ -55,6 +75,15 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final l10n = context.l10n; final l10n = context.l10n;
final isLastPage = _currentPage == _totalPages - 1; final isLastPage = _currentPage == _totalPages - 1;
final scale = _responsiveScale(context: context, min: 0.86, max: 1.05);
final textScale = _effectiveTextScale(context);
final topBarPaddingH = 24 * scale;
final topBarPaddingV = 16 * scale;
final pageIndicatorHeight = 8 * scale;
final pageIndicatorWidth = 8 * scale;
final activeIndicatorWidth = 32 * scale;
final bottomGap = (32 * scale) + ((textScale - 1) * 8);
final actionButtonHeight = (56 * scale) + ((textScale - 1) * 6);
return Scaffold( return Scaffold(
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -63,7 +92,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
children: [ children: [
// Top Navigation Bar // Top Navigation Bar
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
vertical: topBarPaddingV,
),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -199,9 +231,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
margin: const EdgeInsets.symmetric(horizontal: 4), margin: EdgeInsets.symmetric(horizontal: 4 * scale),
height: 8, height: pageIndicatorHeight,
width: isActive ? 32 : 8, width: isActive
? activeIndicatorWidth
: pageIndicatorWidth,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive color: isActive
? colorScheme.primary ? colorScheme.primary
@@ -211,11 +245,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
); );
}), }),
), ),
const SizedBox(height: 32), SizedBox(height: bottomGap),
// Action Button // Action Button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 56, height: actionButtonHeight,
child: FilledButton( child: FilledButton(
onPressed: _nextPage, onPressed: _nextPage,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@@ -520,104 +554,114 @@ class _InteractiveDownloadExampleState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return LayoutBuilder(
padding: const EdgeInsets.all(20), builder: (context, constraints) {
decoration: BoxDecoration( final cardWidth = constraints.maxWidth;
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
borderRadius: BorderRadius.circular(28), final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
border: Border.all( final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
), return Container(
), padding: const EdgeInsets.all(20),
child: Row( decoration: BoxDecoration(
children: [ color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
Container( borderRadius: BorderRadius.circular(28),
width: 72, border: Border.all(
height: 72, color: colorScheme.outlineVariant.withValues(alpha: 0.5),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: 36,
color: colorScheme.onPrimaryContainer,
), ),
), ),
const SizedBox(width: 20), child: Row(
Expanded( children: [
child: Column( Container(
crossAxisAlignment: CrossAxisAlignment.start, width: coverSize,
children: [ height: coverSize,
Container( decoration: BoxDecoration(
width: 140, color: colorScheme.primaryContainer,
height: 14, borderRadius: BorderRadius.circular(20),
decoration: BoxDecoration( ),
color: colorScheme.onSurface, child: Icon(
borderRadius: BorderRadius.circular(7), Icons.album_rounded,
), size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
), ),
const SizedBox(height: 10),
if (_isDownloading)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: _progress,
minHeight: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
else
Container(
width: 90,
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (_isCompleted ? Colors.green : colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
), ),
child: _isDownloading const SizedBox(width: 20),
? SizedBox( Expanded(
width: 28, child: Column(
height: 28, crossAxisAlignment: CrossAxisAlignment.start,
child: CircularProgressIndicator( children: [
strokeWidth: 3, Container(
color: colorScheme.onPrimary, width: (cardWidth * 0.35).clamp(100.0, 160.0),
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(7),
), ),
)
: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: 28,
), ),
), const SizedBox(height: 10),
if (_isDownloading)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: _progress,
minHeight: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
else
Container(
width: (cardWidth * 0.22).clamp(70.0, 100.0),
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.all(buttonPadding),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color:
(_isCompleted ? Colors.green : colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: _isDownloading
? SizedBox(
width: buttonIconSize,
height: buttonIconSize,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.onPrimary,
),
)
: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: buttonIconSize,
),
),
),
],
), ),
], );
), },
); );
} }
} }
@@ -644,6 +688,18 @@ class _TutorialPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final scale = (shortestSide / 390).clamp(0.86, 1.05);
final topGap = (24 * scale).clamp(16.0, 24.0);
final iconPadding = (24 * scale).clamp(18.0, 24.0);
final iconSize = (56 * scale).clamp(44.0, 56.0);
final iconTextGap = (48 * scale).clamp(28.0, 48.0);
final descriptionGap = (20 * scale).clamp(12.0, 20.0);
final contentGap = (56 * scale) + ((textScale - 1) * 10);
final bottomGap = (32 * scale).clamp(20.0, 32.0);
// Parallax effect logic (simplified for StatelessWidget) // Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable // In a real advanced implementation we'd pass the Controller's listenable
@@ -656,23 +712,23 @@ class _TutorialPage extends StatelessWidget {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 24), SizedBox(height: topGap),
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0), transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
padding: const EdgeInsets.all(24), padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15), color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
icon, icon,
size: 56, size: iconSize,
color: iconColor ?? colorScheme.primary, color: iconColor ?? colorScheme.primary,
), ),
), ),
const SizedBox(height: 48), SizedBox(height: iconTextGap),
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0, opacity: isActive ? 1.0 : 0.0,
@@ -687,7 +743,7 @@ class _TutorialPage extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 20), SizedBox(height: descriptionGap),
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0, opacity: isActive ? 1.0 : 0.0,
@@ -697,14 +753,14 @@ class _TutorialPage extends StatelessWidget {
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
height: 1.5, height: 1.5,
fontSize: 16, fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 56), SizedBox(height: contentGap),
content, // The content itself now handles its own internal animations content, // The content itself now handles its own internal animations
const SizedBox(height: 32), SizedBox(height: bottomGap),
], ],
), ),
); );
+21 -6
View File
@@ -11,6 +11,7 @@ final _log = AppLogger('FFmpeg');
class FFmpegService { class FFmpegService {
static const int _commandLogPreviewLength = 300; static const int _commandLogPreviewLength = 300;
static int _tempEmbedCounter = 0;
static String _buildOutputPath(String inputPath, String extension) { static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
@@ -47,6 +48,14 @@ class FFmpegService {
return '${redacted.substring(0, _commandLogPreviewLength)}...'; return '${redacted.substring(0, _commandLogPreviewLength)}...';
} }
static String _nextTempEmbedPath(String tempDirPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
_tempEmbedCounter = (_tempEmbedCounter + 1) & 0x7fffffff;
final timestamp = DateTime.now().microsecondsSinceEpoch;
final processId = pid;
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
}
static Future<FFmpegResult> _execute(String command) async { static Future<FFmpegResult> _execute(String command) async {
try { try {
final session = await FFmpegKit.execute(command); final session = await FFmpegKit.execute(command);
@@ -269,8 +278,7 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" '); cmdBuffer.write('-i "$flacPath" ');
@@ -347,8 +355,7 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" '); cmdBuffer.write('-i "$mp3Path" ');
@@ -358,6 +365,7 @@ class FFmpegService {
} }
cmdBuffer.write('-map 0:a '); cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
if (coverPath != null) { if (coverPath != null) {
cmdBuffer.write('-map 1:0 '); cmdBuffer.write('-map 1:0 ');
@@ -429,12 +437,13 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$opusPath" '); cmdBuffer.write('-i "$opusPath" ');
cmdBuffer.write('-map 0:a '); cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
cmdBuffer.write('-map_metadata:s:a -1 ');
cmdBuffer.write('-c:a copy '); cmdBuffer.write('-c:a copy ');
if (metadata != null) { if (metadata != null) {
@@ -648,6 +657,12 @@ class FFmpegService {
case 'UNSYNCEDLYRICS': case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value; id3Map['lyrics'] = value;
break; break;
case 'COMPOSER':
id3Map['composer'] = value;
break;
case 'COMMENT':
id3Map['comment'] = value;
break;
default: default:
id3Map[key.toLowerCase()] = value; id3Map[key.toLowerCase()] = value;
} }
+133
View File
@@ -374,6 +374,55 @@ class PlatformBridge {
await _channel.invokeMethod('cleanupConnections'); await _channel.invokeMethod('cleanupConnections');
} }
static Future<Map<String, dynamic>> downloadCoverToFile(
String coverUrl,
String outputPath, {
bool maxQuality = true,
}) async {
final result = await _channel.invokeMethod('downloadCoverToFile', {
'cover_url': coverUrl,
'output_path': outputPath,
'max_quality': maxQuality,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> extractCoverToFile(
String audioPath,
String outputPath,
) async {
final result = await _channel.invokeMethod('extractCoverToFile', {
'audio_path': audioPath,
'output_path': outputPath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> fetchAndSaveLyrics({
required String trackName,
required String artistName,
required String spotifyId,
required int durationMs,
required String outputPath,
}) async {
final result = await _channel.invokeMethod('fetchAndSaveLyrics', {
'track_name': trackName,
'artist_name': artistName,
'spotify_id': spotifyId,
'duration_ms': durationMs,
'output_path': outputPath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> reEnrichFile(Map<String, dynamic> request) async {
final requestJSON = jsonEncode(request);
final result = await _channel.invokeMethod('reEnrichFile', {
'request_json': requestJSON,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async { static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', { final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath, 'file_path': filePath,
@@ -381,6 +430,27 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
static Future<Map<String, dynamic>> editFileMetadata(
String filePath,
Map<String, String> metadata,
) async {
final metadataJSON = jsonEncode(metadata);
final result = await _channel.invokeMethod('editFileMetadata', {
'file_path': filePath,
'metadata_json': metadataJSON,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
final result = await _channel.invokeMethod('writeTempToSaf', {
'temp_path': tempPath,
'saf_uri': safUri,
});
final map = jsonDecode(result as String) as Map<String, dynamic>;
return map['success'] == true;
}
static Future<void> startDownloadService({ static Future<void> startDownloadService({
String trackName = '', String trackName = '',
String artistName = '', String artistName = '',
@@ -1117,4 +1187,67 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
_log.d('clearStoreCache'); _log.d('clearStoreCache');
await _channel.invokeMethod('clearStoreCache'); await _channel.invokeMethod('clearStoreCache');
} }
// ==================== YOUTUBE / COBALT ====================
/// Download a track from YouTube using the Cobalt API.
/// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps).
/// It does NOT participate in the lossless fallback chain.
static Future<Map<String, dynamic>> downloadFromYouTube({
required String trackName,
required String artistName,
required String albumName,
String? albumArtist,
String? coverUrl,
required String outputDir,
required String filenameFormat,
String quality = 'opus_256',
int trackNumber = 1,
int discNumber = 1,
String? releaseDate,
String? itemId,
int durationMs = 0,
String? isrc,
String? spotifyId,
String? deezerId,
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)');
final request = jsonEncode({
'track_name': trackName,
'artist_name': artistName,
'album_name': albumName,
'album_artist': albumArtist ?? artistName,
'cover_url': coverUrl,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'track_number': trackNumber,
'disc_number': discNumber,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
'duration_ms': durationMs,
'isrc': isrc ?? '',
'spotify_id': spotifyId ?? '',
'deezer_id': deezerId ?? '',
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadFromYouTube', request);
final response = jsonDecode(result as String) as Map<String, dynamic>;
if (response['success'] == true) {
_log.i('YouTube download success: ${response['file_path']}');
} else {
_log.w('YouTube download failed: ${response['error']}');
}
return response;
}
} }
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
const double kNormalizedHeaderTopPadding = 24.0;
double normalizedHeaderTopPadding(
BuildContext context, {
double max = kNormalizedHeaderTopPadding,
}) {
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
return 0;
}
final topPadding = MediaQuery.paddingOf(context).top;
if (topPadding <= 0) return 0;
return topPadding > max ? max : topPadding;
}
+129
View File
@@ -1,9 +1,138 @@
import 'dart:io'; import 'dart:io';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
/// Regular expression to detect iOS app container paths.
/// Matches paths like /var/mobile/Containers/Data/Application/{UUID}
/// or /private/var/mobile/Containers/Data/Application/{UUID}
final _iosContainerRootPattern = RegExp(
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
caseSensitive: false,
);
/// Checks if a path is a valid writable directory on iOS.
/// Returns false if:
/// - The path is the app container root (not writable)
/// - The path is an iCloud Drive path (not accessible by Go backend)
/// - The path is outside the app sandbox
bool isValidIosWritablePath(String path) {
if (!Platform.isIOS) return true;
if (path.isEmpty) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
return false;
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
);
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
// Valid paths should have something after the UUID
if (remainingPath.isEmpty || remainingPath == '/') {
return false;
}
}
return true;
}
/// Validates and potentially corrects an iOS path.
/// Returns a valid Documents subdirectory path if the input is invalid.
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async {
if (!Platform.isIOS) return path;
if (isValidIosWritablePath(path)) {
return path;
}
// Fall back to app Documents directory
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/$subfolder');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
return musicDir.path;
}
/// Detailed result for iOS path validation
class IosPathValidationResult {
final bool isValid;
final String? correctedPath;
final String? errorReason;
const IosPathValidationResult({
required this.isValid,
this.correctedPath,
this.errorReason,
});
}
/// Validates an iOS path and returns detailed information about the result.
IosPathValidationResult validateIosPath(String path) {
if (!Platform.isIOS) {
return const IosPathValidationResult(isValid: true);
}
if (path.isEmpty) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Path is empty',
);
}
// Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.',
);
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'iCloud Drive is not supported. Please choose a local folder.',
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
);
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
if (remainingPath.isEmpty || remainingPath == '/') {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.',
);
}
}
return const IosPathValidationResult(isValid: true);
}
class FileAccessStat { class FileAccessStat {
final int? size; final int? size;
final DateTime? modified; final DateTime? modified;
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// A collapsing header widget /// A collapsing header widget
/// Title collapses from large to small when scrolling /// Title collapses from large to small when scrolling
@@ -19,7 +20,7 @@ class CollapsingHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
+25 -137
View File
@@ -22,7 +22,9 @@ class BuiltInService {
}); });
} }
/// Default quality options for built-in services (Tidal, Qobuz, Amazon) /// Default quality options for built-in services (Tidal, Qobuz, YouTube)
/// Note: Amazon is fallback-only and not shown in picker
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
const _builtInServices = [ const _builtInServices = [
BuiltInService( BuiltInService(
id: 'tidal', id: 'tidal',
@@ -31,7 +33,6 @@ const _builtInServices = [
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'),
], ],
), ),
BuiltInService( BuiltInService(
@@ -44,15 +45,14 @@ const _builtInServices = [
], ],
), ),
BuiltInService( BuiltInService(
id: 'amazon', id: 'youtube',
label: 'Amazon', label: 'YouTube',
qualityOptions: [ qualityOptions: [
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'),
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
], ],
isDisabled: true, isDisabled: false,
disabledReason: 'Fallback only', disabledReason: null,
), ),
]; ];
@@ -211,7 +211,7 @@ Padding(
), ),
), ),
if (_builtInServices.any((s) => s.id == _selectedService)) if (_builtInServices.any((s) => s.id == _selectedService && s.id != 'youtube'))
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text( child: Text(
@@ -223,19 +223,26 @@ Padding(
), ),
), ),
if (_selectedService == 'youtube')
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.youtubeQualityNote,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
for (final quality in qualityOptions) for (final quality in qualityOptions)
_QualityOption( _QualityOption(
title: quality.label, title: quality.label,
subtitle: quality.description ?? '', subtitle: quality.description ?? '',
icon: _getQualityIcon(quality.id), icon: _getQualityIcon(quality.id),
onTap: () { onTap: () {
// For Tidal HIGH quality, show format picker first Navigator.pop(context);
if (_selectedService == 'tidal' && quality.id == 'HIGH') { widget.onSelect(quality.id, _selectedService);
_showLossyFormatPicker(context);
} else {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
}
}, },
), ),
@@ -254,136 +261,17 @@ Padding(
return Icons.high_quality; return Icons.high_quality;
case 'LOSSLESS': case 'LOSSLESS':
return Icons.music_note; return Icons.music_note;
case 'HIGH':
return Icons.aod;
case 'MP3_320': case 'MP3_320':
case 'MP3': case 'MP3':
return Icons.audiotrack; return Icons.audiotrack;
case 'OPUS': case 'OPUS':
case 'OPUS_128': case 'OPUS_128':
case 'OPUS_256':
return Icons.graphic_eq; return Icons.graphic_eq;
default: default:
return Icons.music_note; return Icons.music_note;
} }
} }
void _showLossyFormatPicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
final currentFormat = settings.tidalHighFormat;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (modalContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Select Lossy Format',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose output format for 320kbps lossy download',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: currentFormat == 'mp3_320'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 256kbps'),
subtitle: const Text('Best quality Opus, ~8MB per track'),
trailing: currentFormat == 'opus_256'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 128kbps'),
subtitle: const Text('Smallest size, ~4MB per track'),
trailing: currentFormat == 'opus_128'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
const SizedBox(height: 16),
],
),
),
);
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.5.1+75 version: 3.6.0+77
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0