Compare commits

...

158 Commits

Author SHA1 Message Date
zarzet 0dc89cf569 fix(deps): allow material_color_utilities sdk-pinned range 2026-02-12 02:33:54 +07:00
zarzet 3c1e9d03a0 fix(ios): recover notification permission and path handling 2026-02-12 02:23:54 +07:00
zarzet 28a082f47a fix(l10n): fix arb locale mismatch - rename es-ES/pt-PT to underscore format 2026-02-12 01:28:56 +07:00
zarzet 38994d5900 chore: bump pubspec.yaml version to 3.6.6+80 2026-02-12 01:16:49 +07:00
zarzet 472896328a Merge remote-tracking branch 'origin/dev' into dev 2026-02-12 01:15:50 +07:00
zarzet 92f408035a feat: enable library scan notifications on iOS (remove Android-only guard) 2026-02-12 01:14:10 +07:00
zarzet 979186243c docs: update changelog v3.6.6 with new features and translation update 2026-02-12 01:11:12 +07:00
zarzet ee66247bea Merge remote-tracking branch 'origin/l10n_dev' into dev
# Conflicts:
#	lib/l10n/arb/app_es-ES.arb
#	lib/l10n/arb/app_id.arb
#	lib/l10n/arb/app_pt-PT.arb
2026-02-12 01:10:04 +07:00
zarzet 66a9daf733 chore: minor formatting and whitespace cleanup 2026-02-12 01:06:27 +07:00
zarzet 69a9e0cb40 feat: add library scan notifications - progress, complete, failed, cancelled notifications for local library scan - new notification channel for library scan - Android only 2026-02-12 01:06:17 +07:00
zarzet cd6beaa7d4 feat: add filterContributingArtistsInAlbumArtist setting - new option to strip contributing artists from Album Artist metadata - applies to folder organization and metadata embedding - collapsible Artist Name Filters section in download settings UI 2026-02-12 01:06:08 +07:00
zarzet 5f4ff17630 refactor(ios): remove legacy download handlers, use downloadByStrategy only 2026-02-12 01:02:48 +07:00
zarzet 3c3bbe516e v3.6.6: fix iOS downloads, metadata fallback, lossy quality display, audio duration accuracy 2026-02-12 00:32:40 +07:00
zarzet a1d1ab1f0f fix: preserve extended metadata during fallback, accurate lossy quality display, SAF improvements
- Add Genre/Label/Copyright fields to DownloadResult struct
- buildDownloadSuccessResponse now prefers service result metadata over request
- enrichRequestExtendedMetadata fetches Deezer metadata by ISRC before download
- Flutter sends copyright in download request payload
- History merge preserves existing genre/label/copyright on re-download
- Accurate MP3 duration via Xing/VBRI VBR headers, MPEG2/2.5 bitrate tables
- Accurate Opus/Vorbis duration via last Ogg page granule position
- Bitrate field added to LibraryScanResult, LocalLibraryItem, DB v4 migration
- Lossy formats display format+bitrate instead of fake 16-bit quality
- Local library file date uses fileModTime instead of scannedAt
- SAF URI recovery for transient FD paths after download
- Improved SAF repair and download history path matching in library scan
- Extract quality probe logic into reusable enrichResultQualityFromFile
2026-02-12 00:19:02 +07:00
Zarz Eleutherius ab9456fff8 New translations app_en.arb (Turkish) 2026-02-11 16:12:15 +07:00
Zarz Eleutherius 2f673469aa New translations app_en.arb (Hindi) 2026-02-11 16:12:14 +07:00
Zarz Eleutherius 05fde22075 New translations app_en.arb (Indonesian) 2026-02-11 16:12:13 +07:00
Zarz Eleutherius deab7b7dd6 New translations app_en.arb (Chinese Traditional) 2026-02-11 16:12:11 +07:00
Zarz Eleutherius ae5da3b6e0 New translations app_en.arb (Chinese Simplified) 2026-02-11 16:12:10 +07:00
Zarz Eleutherius 4d0c8f49aa New translations app_en.arb (Russian) 2026-02-11 16:12:08 +07:00
Zarz Eleutherius 3068f4e367 New translations app_en.arb (Portuguese) 2026-02-11 16:12:07 +07:00
Zarz Eleutherius 3844704490 New translations app_en.arb (Dutch) 2026-02-11 16:12:06 +07:00
Zarz Eleutherius 12144b8220 New translations app_en.arb (Korean) 2026-02-11 16:12:04 +07:00
Zarz Eleutherius b639080494 New translations app_en.arb (Japanese) 2026-02-11 16:12:03 +07:00
Zarz Eleutherius e67d7d68cb New translations app_en.arb (German) 2026-02-11 16:12:01 +07:00
Zarz Eleutherius b8f18c1cf5 New translations app_en.arb (Spanish) 2026-02-11 16:12:00 +07:00
Zarz Eleutherius 529958c4af New translations app_en.arb (French) 2026-02-11 16:11:59 +07:00
Zarz Eleutherius 40077a577c Update source file app_en.arb 2026-02-11 16:11:57 +07:00
Zarz Eleutherius e0fbd706ce Merge pull request #145 from zarzet/renovate/go-dependencies
fix(deps): update go dependencies
2026-02-11 16:10:59 +07:00
Zarz Eleutherius b76879f204 Merge pull request #148 from zarzet/renovate/go-1.x
chore(deps): update dependency go to 1.26
2026-02-11 16:10:47 +07:00
zarzet abc599d7f9 refactor: migrate queue_tab cover resolver to shared service, add supporter 2026-02-11 12:31:47 +07:00
renovate[bot] eefbb63299 fix(deps): update go dependencies 2026-02-11 05:31:18 +00:00
renovate[bot] fdbb474763 chore(deps): update dependency go to 1.26 2026-02-11 05:30:57 +00:00
zarzet 6a7eef6956 chore: add Elias el Autentico to supporters list 2026-02-11 12:28:50 +07:00
zarzet 9b27e86e0f fix: show library filter buttons while downloads are active
Previously filter/sort headers in All, Albums, and Singles tabs
were hidden when queue items existed, preventing users from
filtering their library (e.g. find MP3 tracks to re-download
as FLAC) during active downloads.
2026-02-11 08:01:11 +07:00
zarzet dbe8f5d814 docs: add batch 3 performance entries to changelog, fix device ID entry 2026-02-11 02:41:32 +07:00
zarzet 9847594ca1 perf: parallel I/O, caching, and chunked DB operations (batch 3)
- Orphan cleanup: parallel file existence checks (chunk 16)
- LocalLibraryState: O(1) findByTrackAndArtist via _byTrackKey map
- Local library load: parallel DB + SharedPreferences fetch
- Legacy mod-time backfill: chunked parallel File.stat (chunk 24)
- Downloaded album screen: cache disc groups, quality, cover path
- Local album screen: cache common quality, map-based batch delete
- Cache management: parallel async init, chunked directory cleanup
- Cover resolver: throttled preview exists check (2.2s interval)
- History/Library DB: chunked SQL DELETE (500 per batch)
- Batch delete screens: O(1) item lookup via tracksById map
2026-02-11 02:40:09 +07:00
zarzet 986f5eafc8 docs: add performance, security, and UI sections to v3.6.5 changelog 2026-02-11 02:10:28 +07:00
zarzet 84df64fcfe perf+security: polling guards, sensitive data redaction, SAF path sanitization
Go backend:
- Add sensitive data redaction in log buffer (tokens, keys, passwords)
- Validate extension auth URLs (HTTPS only, no private IPs, no embedded creds)
- Block embedded credentials in extension HTTP requests
- Tighten extension storage file permissions (0644 -> 0600)
- Sanitize extension ID in store download path
- Summarize auth URLs in logs to prevent token leakage

Android (Kotlin):
- Add sanitizeRelativeDir to prevent path traversal in SAF operations
- Apply sanitizeFilename to all user-provided file names in SAF

Flutter:
- Add sensitive data redaction in Dart logger (mirrors Go patterns)
- Mask device ID in log exports
- Add in-flight guard to progress polling (download queue + local library)
- Remove redundant _downloadedSpotifyIds Set, use _bySpotifyId map
- Remove redundant _isrcSet, use _byIsrc map
- Expand DownloadQueueLookup with byItemId and itemIds
- Lazy search index building in queue tab
- Bound embedded cover cache in queue tab (max 180)
- Coalesce embedded cover refresh callbacks via postFrameCallback
- Cache album track filtering in downloaded album screen
- Cache thumbnail sizes by extension ID in home tab
- Simplify recent access aggregation (single-pass)
- Remove unused _isTyping state in home tab
- Cap pre-warm track batch size to 80
- Skip setShowingRecentAccess if value unchanged
- Use downloadQueueLookupProvider for granular queue selectors
- Move grouped album filtering before content data computation
2026-02-11 02:02:03 +07:00
zarzet a9150b85b9 perf: memory and rebuild optimizations across app
- Bound Deezer cache with LRU eviction and periodic cleanup
- Configure Flutter image cache limits (240 entries / 60 MiB)
- Add ResizeImage wrapper for precacheImage calls
- Add memCacheWidth/cacheWidth to cover images across screens
- Add DownloadedEmbeddedCoverResolver as centralized cover service
- Throttle download progress notifications with dedup checks
- Normalize progress/speed/bytes values to reduce UI rebuilds
- Optimize queue list with per-item ConsumerWidget and RepaintBoundary
- Preserve derived indexes in LocalLibraryState.copyWith
- Skip non-error logs when detailed logging disabled
- Use async file stat and early-break loops in queue filters
2026-02-11 01:44:05 +07:00
zarzet 68e6c8be35 ui: improve cover preview in edit metadata sheet and user changes
- Cover preview enlarged from 120x120 to 160x160 with shadow and better styling
- Layout changed from Wrap to Row with Expanded for side-by-side covers
- Label moved below image with labelMedium typography
- Cover editor section moved to top of edit form
- Added embedded cover preview cache with LRU eviction in metadata screen
- Added current cover extraction and preview in edit metadata sheet
- Added metadata sync to download history after edits
- Added embedded cover extraction cache in queue tab for downloaded items
- Added SAF mod-time tracking for cover refresh after metadata changes
2026-02-11 01:13:24 +07:00
zarzet bd42655c0e fix: various improvements and fixes 2026-02-11 00:22:48 +07:00
zarzet fe1c96ea12 v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled 2026-02-10 23:35:41 +07:00
zarzet bae2bf63eb chore: remove Buy Me a Coffee references (account suspended) 2026-02-10 20:46:45 +07:00
Zarz Eleutherius 803e0dc5a3 New translations app_en.arb (Turkish) 2026-02-10 19:46:50 +07:00
Zarz Eleutherius 474c37ec8e New translations app_en.arb (Hindi) 2026-02-10 19:46:46 +07:00
Zarz Eleutherius eb7726263a New translations app_en.arb (Indonesian) 2026-02-10 19:46:45 +07:00
Zarz Eleutherius f87ccc51c5 New translations app_en.arb (Chinese Traditional) 2026-02-10 19:46:43 +07:00
Zarz Eleutherius b0b4e7803c New translations app_en.arb (Chinese Simplified) 2026-02-10 19:46:42 +07:00
Zarz Eleutherius 450f19c656 New translations app_en.arb (Russian) 2026-02-10 19:46:41 +07:00
Zarz Eleutherius 55b9c08f99 New translations app_en.arb (Portuguese) 2026-02-10 19:46:39 +07:00
Zarz Eleutherius a5f3aab775 New translations app_en.arb (Dutch) 2026-02-10 19:46:38 +07:00
Zarz Eleutherius 7442c9b106 New translations app_en.arb (Korean) 2026-02-10 19:46:37 +07:00
Zarz Eleutherius ae66cb478b New translations app_en.arb (Japanese) 2026-02-10 19:46:35 +07:00
Zarz Eleutherius 2516c3e618 New translations app_en.arb (German) 2026-02-10 19:46:34 +07:00
Zarz Eleutherius 02a5893279 New translations app_en.arb (Spanish) 2026-02-10 19:46:33 +07:00
Zarz Eleutherius bd0d653210 New translations app_en.arb (French) 2026-02-10 19:46:31 +07:00
Zarz Eleutherius 62626ddc08 Update source file app_en.arb 2026-02-10 19:46:29 +07:00
zarzet b6574f0097 refactor: preserve extension ID case in DownloadByStrategy, only lowercase built-in providers 2026-02-10 12:30:38 +07:00
zarzet c35a8dd803 refactor: remove deprecated download methods from PlatformBridge and MainActivity 2026-02-10 10:16:55 +07:00
zarzet d54b2249b6 v3.6.1: fix lyrics_mode, notification v20, SAF duplicate, primary artist setting, unified download strategy 2026-02-10 10:11:02 +07:00
zarzet f7be2c1e12 feat: primary artist only folders, fix notifications v20, fix SAF duplicate dirs
- Add 'Use Primary Artist Only' setting to strip featured artists from folder names
- Fix flutter_local_notifications v20 breaking changes (positional params)
- Fix SAF duplicate folder bug: synchronized ensureDocumentDir to prevent race condition creating empty folders with (1), (2) suffixes during concurrent downloads
2026-02-10 09:07:18 +07:00
zarzet ebe7d87da7 docs: update VirusTotal hash for v3.6.0 2026-02-10 01:03:58 +07:00
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
Zarz Eleutherius 9cd2b1d8c5 New translations app_en.arb (Russian) 2026-02-09 19:41:00 +07:00
Zarz Eleutherius 49f1fb43fa New translations app_en.arb (Spanish) 2026-02-09 19:40:58 +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
Zarz Eleutherius 65b521ff8b New translations app_en.arb (Turkish) 2026-02-08 19:17:59 +07:00
Zarz Eleutherius 6d578694e2 New translations app_en.arb (Hindi) 2026-02-08 19:17:58 +07:00
Zarz Eleutherius f7ec649b24 New translations app_en.arb (Indonesian) 2026-02-08 19:17:57 +07:00
Zarz Eleutherius 71a9e1baef New translations app_en.arb (Chinese Traditional) 2026-02-08 19:17:56 +07:00
Zarz Eleutherius 4a4adcb72e New translations app_en.arb (Chinese Simplified) 2026-02-08 19:17:55 +07:00
Zarz Eleutherius 3458f03158 New translations app_en.arb (Russian) 2026-02-08 19:17:54 +07:00
Zarz Eleutherius 4fe4a01840 New translations app_en.arb (Portuguese) 2026-02-08 19:17:53 +07:00
Zarz Eleutherius e5d6fddeda New translations app_en.arb (Dutch) 2026-02-08 19:17:52 +07:00
Zarz Eleutherius 370f5e3b8b New translations app_en.arb (Korean) 2026-02-08 19:17:51 +07:00
Zarz Eleutherius f5bb0820d5 New translations app_en.arb (Japanese) 2026-02-08 19:17:50 +07:00
Zarz Eleutherius feb6da3ecb New translations app_en.arb (German) 2026-02-08 19:17:48 +07:00
Zarz Eleutherius 39f28a12aa New translations app_en.arb (Spanish) 2026-02-08 19:17:47 +07:00
Zarz Eleutherius 416fc79637 New translations app_en.arb (French) 2026-02-08 19:17:46 +07:00
Zarz Eleutherius 1f43780bec Update source file app_en.arb 2026-02-08 19:17:44 +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 481b4b03dc New translations app_en.arb (Turkish) 2026-02-07 19:20:11 +07:00
Zarz Eleutherius b7fd2f7902 New translations app_en.arb (Hindi) 2026-02-07 19:20:10 +07:00
Zarz Eleutherius f2e1e59d6a New translations app_en.arb (Indonesian) 2026-02-07 19:20:09 +07:00
Zarz Eleutherius 3af2ecf1f4 New translations app_en.arb (Chinese Traditional) 2026-02-07 19:20:08 +07:00
Zarz Eleutherius 1b2f2c891c New translations app_en.arb (Chinese Simplified) 2026-02-07 19:20:07 +07:00
Zarz Eleutherius 155f3259f2 New translations app_en.arb (Russian) 2026-02-07 19:20:06 +07:00
Zarz Eleutherius f52d8d68b8 New translations app_en.arb (Portuguese) 2026-02-07 19:20:05 +07:00
Zarz Eleutherius 216d6e152c New translations app_en.arb (Dutch) 2026-02-07 19:20:04 +07:00
Zarz Eleutherius b6f90e727c New translations app_en.arb (Korean) 2026-02-07 19:20:03 +07:00
Zarz Eleutherius 790bbc544f New translations app_en.arb (Japanese) 2026-02-07 19:20:02 +07:00
Zarz Eleutherius bd511f7dc6 New translations app_en.arb (German) 2026-02-07 19:20:01 +07:00
Zarz Eleutherius e91c8c28a8 New translations app_en.arb (Spanish) 2026-02-07 19:20:00 +07:00
Zarz Eleutherius 3c6d1afa97 New translations app_en.arb (French) 2026-02-07 19:19:59 +07:00
Zarz Eleutherius 3947e109b4 Update source file app_en.arb 2026-02-07 19:19:57 +07:00
Zarz Eleutherius af203ae51f Update VirusTotal badge link in README 2026-02-07 14:44:19 +07:00
Zarz Eleutherius bf87662f99 New translations app_en.arb (Russian) 2026-02-06 18:33:40 +07:00
Zarz Eleutherius 4273edd836 New translations app_en.arb (Russian) 2026-02-05 13:19:12 +07:00
Zarz Eleutherius 7ce41fc1c1 Update source file app_en.arb 2026-02-05 13:19:10 +07:00
Zarz Eleutherius fb7a576e00 New translations app_en.arb (Turkish) 2026-02-04 12:53:12 +07:00
Zarz Eleutherius 30a559b279 New translations app_en.arb (Hindi) 2026-02-04 12:53:11 +07:00
Zarz Eleutherius f77d5fdf14 New translations app_en.arb (Indonesian) 2026-02-04 12:53:10 +07:00
Zarz Eleutherius 0a0667889c New translations app_en.arb (Chinese Traditional) 2026-02-04 12:53:09 +07:00
Zarz Eleutherius 14d8cd54d7 New translations app_en.arb (Chinese Simplified) 2026-02-04 12:53:08 +07:00
Zarz Eleutherius 5fa3d405e6 New translations app_en.arb (Russian) 2026-02-04 12:53:07 +07:00
Zarz Eleutherius 34eb335fd0 New translations app_en.arb (Portuguese) 2026-02-04 12:53:06 +07:00
Zarz Eleutherius c910530927 New translations app_en.arb (Dutch) 2026-02-04 12:53:05 +07:00
Zarz Eleutherius 69e1a6cf6b New translations app_en.arb (Korean) 2026-02-04 12:53:04 +07:00
Zarz Eleutherius bd84613624 New translations app_en.arb (Japanese) 2026-02-04 12:53:02 +07:00
Zarz Eleutherius 0b4777fc6b New translations app_en.arb (German) 2026-02-04 12:53:01 +07:00
Zarz Eleutherius e22813caec New translations app_en.arb (Spanish) 2026-02-04 12:53:00 +07:00
Zarz Eleutherius 8f6e8432de New translations app_en.arb (French) 2026-02-04 12:52:59 +07:00
Zarz Eleutherius b3c98cecc3 New translations app_en.arb (Turkish) 2026-02-02 08:25:49 +07:00
Zarz Eleutherius 49a18a977b New translations app_en.arb (Hindi) 2026-02-02 08:25:48 +07:00
Zarz Eleutherius a5d0feeedf New translations app_en.arb (Indonesian) 2026-02-02 08:25:47 +07:00
Zarz Eleutherius a574e73b44 New translations app_en.arb (Chinese Traditional) 2026-02-02 08:25:46 +07:00
Zarz Eleutherius a66f6a739f New translations app_en.arb (Chinese Simplified) 2026-02-02 08:25:45 +07:00
Zarz Eleutherius cc7e1b54b6 New translations app_en.arb (Russian) 2026-02-02 08:25:44 +07:00
Zarz Eleutherius 28cb7fcd3d New translations app_en.arb (Portuguese) 2026-02-02 08:25:43 +07:00
Zarz Eleutherius aeb370beca New translations app_en.arb (Dutch) 2026-02-02 08:25:42 +07:00
Zarz Eleutherius 239707e2da New translations app_en.arb (Korean) 2026-02-02 08:25:41 +07:00
Zarz Eleutherius c1e2778735 New translations app_en.arb (Japanese) 2026-02-02 08:25:40 +07:00
Zarz Eleutherius fb608a554d New translations app_en.arb (German) 2026-02-02 08:25:39 +07:00
Zarz Eleutherius 7561065802 New translations app_en.arb (Spanish) 2026-02-02 08:25:38 +07:00
Zarz Eleutherius 56c8d89999 New translations app_en.arb (French) 2026-02-02 08:25:37 +07:00
Zarz Eleutherius 9192760f3c Update source file app_en.arb 2026-02-02 08:25:35 +07:00
Zarz Eleutherius 40ec24db69 New translations app_en.arb (Turkish) 2026-02-01 08:04:39 +07:00
Zarz Eleutherius ba8d0a3438 New translations app_en.arb (Hindi) 2026-02-01 08:04:38 +07:00
Zarz Eleutherius 82decf99a6 New translations app_en.arb (Indonesian) 2026-02-01 08:04:37 +07:00
Zarz Eleutherius 6ba9fc1fec New translations app_en.arb (Chinese Traditional) 2026-02-01 08:04:36 +07:00
Zarz Eleutherius 715d94c2ed New translations app_en.arb (Chinese Simplified) 2026-02-01 08:04:35 +07:00
Zarz Eleutherius e1a722f479 New translations app_en.arb (Russian) 2026-02-01 08:04:34 +07:00
Zarz Eleutherius edbe12c512 New translations app_en.arb (Portuguese) 2026-02-01 08:04:33 +07:00
Zarz Eleutherius 9fc6542792 New translations app_en.arb (Dutch) 2026-02-01 08:04:32 +07:00
Zarz Eleutherius 4c01ee26c2 New translations app_en.arb (Korean) 2026-02-01 08:04:31 +07:00
Zarz Eleutherius 813b9fcf61 New translations app_en.arb (Japanese) 2026-02-01 08:04:30 +07:00
Zarz Eleutherius fe070e0177 New translations app_en.arb (German) 2026-02-01 08:04:29 +07:00
Zarz Eleutherius 423bb87ed8 New translations app_en.arb (Spanish) 2026-02-01 08:04:28 +07:00
Zarz Eleutherius 1641f51b0c New translations app_en.arb (French) 2026-02-01 08:04:27 +07:00
Zarz Eleutherius 3f78a1f3d1 Update source file app_en.arb 2026-02-01 08:04:25 +07:00
110 changed files with 38959 additions and 9548 deletions
-1
View File
@@ -1,4 +1,3 @@
github: zarzet
ko_fi: zarzet
buy_me_a_coffee: zarzet
+2 -2
View File
@@ -71,7 +71,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.26"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
@@ -174,7 +174,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
go-version: "1.26"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
+282
View File
@@ -1,5 +1,287 @@
# Changelog
## [3.6.6] - 2026-02-12
### Added
- "Filter Contributing Artists in Album Artist" setting - strips featured/contributing artists from Album Artist metadata tag
- Library scan notifications (Android and iOS) - shows progress, completion, failure, and cancellation status
- Collapsible "Artist Name Filters" section in download settings UI
### Fixed
- Fixed downloads not working on iOS - missing `downloadByStrategy` and `downloadFromYouTube` method channel handlers in AppDelegate.swift
- Fixed extended metadata (genre, label, copyright) lost during service fallback (e.g. Tidal unavailable, falls back to Qobuz) - Go backend now enriches metadata from Deezer by ISRC before download and preserves it through the fallback chain
- Fixed local library showing incorrect "16-bit" quality label for lossy formats (MP3, Opus) - now displays actual bitrate (e.g. "MP3 320kbps")
- Fixed inaccurate Opus/Vorbis duration calculation (e.g. 4:11 showing as 8:44) - now reads granule position from last Ogg page for precise duration
- Fixed MP3 duration/bitrate inaccuracy for VBR files - added Xing/Info and VBRI header parsing with MPEG2/2.5 bitrate table support
- Fixed Track Metadata screen showing scan date instead of file date for local library items
- Fixed SAF content URI paths displayed as raw `content://` strings in Track Metadata - now shows human-readable paths
### Changed
- Removed legacy iOS download handlers (`downloadTrack`, `downloadWithFallback`, `downloadFromYouTube`) - iOS now uses `downloadByStrategy` only
- Updated translations from Crowdin (all 14 languages)
---
## [3.6.5] - 2026-02-10
### Highlights
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
### Added
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
- Available in Settings > Download > below "Use Album Artist for folders"
- Audio format conversion from Track Metadata screen
- Convert between FLAC, MP3, and Opus formats (any direction)
- Selectable bitrate: 128k, 192k, 256k, 320k
- Full metadata and cover art preservation during conversion
- Confirmation dialog before converting (original file deleted after)
- SAF storage support: copies to temp, converts, writes back via SAF
- Download history automatically updated with new file path
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
- Added strategy flags in payload: `use_extensions`, `use_fallback`
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
- SpotFetch metadata fallback integration for Spotify-blocked regions
- New backend client for `spotify.afkarxyz.fun/api`
- Automatic fallback in Spotify metadata fetch path when primary source fails
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
- Includes heuristic detection of lyrics stored in Comment fields
- Edit Metadata now supports manual cover selection (pick/replace cover image) and embeds it into audio tags on save
- Save Lyrics now shows an immediate in-progress snackbar (`Saving lyrics...`) so users know the operation has started
### Changed
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
- Track Metadata cover UI now refreshes from the embedded file after Edit Metadata/Re-enrich, so the displayed art matches actual file tags
- Edit Metadata cover section moved to the top of the form and now previews current embedded cover before replacement (plus selected replacement preview)
- Edit Metadata cover preview enlarged (120px to 160px) with shadow, side-by-side layout for current vs selected cover, and label repositioned below image
### Fixed
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
- Inconsistent parameter parity across download paths
- `downloadWithExtensions` now carries `copyright`
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields
- Inconsistent success response metadata between direct/fallback flows
- Added shared Go response builder for `DownloadTrack` and `DownloadWithFallback`
- Success responses now consistently include `genre`, `label`, `copyright`, and `lyrics_lrc`
- YouTube success response now also includes extended metadata fields (`cover_url`, `genre`, `label`, `copyright`) for parity with other providers
- Fixed `Save Lyrics` crash on Android (`java.lang.Integer cannot be cast to java.lang.Long`) by normalizing `duration_ms` channel argument as `Number -> Long`
- Fixed FLAC Re-enrich cover edge case where metadata could be written without cover when temp cover file creation failed; FLAC cover embed now uses in-memory bytes and verifies cover after write
- Fixed FLAC picture-block embed robustness by detecting image MIME via magic bytes (JPEG/PNG/GIF/WEBP) instead of relying on filename extension
- Fixed MP3/Opus metadata rewrite flows to preserve existing embedded cover when no new cover is available
- Fixed Library tab cover not updating after manual cover edit/re-embed for downloaded tracks
- Queue/Library now prefers embedded cover art extracted from local files (not just cached `coverUrl`)
- Added per-track extraction cache with file-modification invalidation so updated embedded art is reflected in Library
- Extraction is now on-demand for edited tracks only (not full-library reload)
- Returning from Track Metadata now refreshes cover cache only for the affected track
- Cover refresh is now skipped when file modification time is unchanged, removing unnecessary flash when simply opening/closing metadata screen
- Fixed repeated cover preview extraction in Track Metadata screen (`track_cover_preview_*`) causing visible flash when reopening
- Added in-memory preview cache keyed by file path so reopening metadata reuses existing preview without re-extract
- Cache validation uses file modification time for filesystem paths; SAF paths are refreshed only after successful edit actions
- Queue/Library now also compares SAF file last-modified (`getSafFileModTimes`) before refreshing embedded-cover cache
- Preview cache key is now stable per track item (not volatile temp SAF path), eliminating false cache misses on SAF-backed files
- Track Metadata no longer auto-extracts cover preview on every screen open; extraction now runs only after actual edit/re-enrich changes (or when explicitly forced)
- Track metadata edits/re-enrich now sync updated tags back into `downloadHistoryProvider` + SQLite history rows
- Non-Library screens that read download history (Home/album/history views) now reflect updated title/artist/album/tags without manual rescan
- Track Metadata back-navigation now returns an explicit update result after successful edits/re-enrich, enabling History-tab cover refresh fallback when SAF timestamps are unreliable
### Performance
- Configured Flutter image cache limits (240 entries / 60 MiB) and added `ResizeImage` wrappers for cover art precaching across all screens, reducing peak memory usage on cover-heavy pages
- Added LRU eviction to Deezer cache with configurable max entries per cache type (search/album/artist/ISRC) and periodic expired-entry cleanup to prevent unbounded memory growth in long sessions
- Download progress notifications are now normalized (2-decimal progress, 1-decimal speed, 0.1 MiB byte steps) and deduplicated by track/artist/percent/queue-count, reducing notification overhead during batch downloads
- Each queue item now uses a dedicated `ConsumerWidget` with per-item `.select()` instead of rebuilding the entire list on any item change; items are wrapped in `RepaintBoundary` for paint isolation
- Queue/Library search indexes are now built on-demand per item instead of upfront for all items, with bounded LRU caches (max 4000 entries)
- `copyWith` now preserves derived lookup indexes (ISRC map, track key set) when items list is unchanged, avoiding O(n) rebuild on every scan progress update
- Scan progress polling now compares values before calling `setState`, skipping unnecessary widget rebuilds when nothing changed
- Added in-flight flag to download progress and library scan polling to prevent concurrent timer callbacks from overlapping
- New `DownloadedEmbeddedCoverResolver` service replaces per-screen cover extraction logic with a shared bounded cache (160 entries), mod-time validation, and throttled refresh checks
- Multiple embedded cover change callbacks are now coalesced into a single frame via `addPostFrameCallback`, preventing redundant rebuilds
- Downloaded album screen now caches filtered/sorted track lists and reuses them when the source data reference is unchanged
- Home tab recent downloads now use single-pass aggregation instead of building full per-album lists, and store only IDs instead of full item objects for the clear-all action
- Removed duplicate `_downloadedSpotifyIds` Set and `_isrcSet` (both now use existing map lookups), removed unused `_isTyping` state in home tab
- Track cache pre-warming is now capped at 80 tracks per request to avoid excessive backend calls on large playlists
- About page contributor avatars now use `memCacheWidth`/`memCacheHeight` to decode at display size instead of full resolution
- Orphaned download cleanup now checks file existence in parallel (chunk 16) instead of sequentially
- Local library `findByTrackAndArtist` now uses O(1) map lookup (`_byTrackKey`) instead of O(n) linear scan
- Local library database load and SharedPreferences fetch now run in parallel
- Legacy mod-time backfill now uses chunked parallel `File.stat` (chunk 24) with per-chunk cancel check
- Downloaded album screen now caches disc grouping, sorted disc numbers, common quality, and embedded cover path with reference-identity invalidation
- Local album screen common quality is now computed once during cache rebuild instead of per-build
- Batch delete in album screens now uses O(1) map lookup (`tracksById`) instead of `.where().firstOrNull`
- Cache management page now fires all async init calls in parallel and uses chunked async directory deletion (chunk 24)
- Cover resolver preview file existence check is now throttled (2.2s interval) to reduce synchronous I/O in build path
- History and library database DELETE operations are now chunked (500 per batch) to stay within SQLite variable limits
- Library database `cleanupMissingFiles` now checks file existence in parallel (chunk 16) and deletes in batched SQL
### Security
- All logs (Go and Dart) now automatically redact Bearer tokens, access/refresh tokens, client secrets, API keys, and passwords using regex-based sanitization before storage
- Extension auth URLs are now validated for HTTPS-only, no embedded credentials, and no private/local network targets before opening
- Auth URLs in logs are summarized to scheme+host+path only (query params stripped) to prevent token leakage; token exchange error bodies are truncated and sanitized
- Extension HTTP requests now block URLs with embedded credentials (`user:pass@host`)
- Extension storage files changed from `0644` to `0600` (owner-only read/write)
- All SAF relative directory paths are now sanitized per-segment with `.`/`..` filtering; all user-provided file names pass through `sanitizeFilename()` before use
- Extension ID is sanitized before building download destination path
- Log export device info now shows Build ID and Security Patch level instead of masked Device ID
### Technical
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
- Go strategy router normalizes incoming service casing before dispatch
- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
- Extension runtime: JS panic handler now logs full stack trace for easier debugging
- `DownloadQueueLookup` expanded with `byItemId` map and `itemIds` list for O(1) queue item access from UI
- Non-error/non-fatal log entries are now skipped entirely (not just hidden) when detailed logging is disabled, reducing buffer growth and Go log polling overhead
### Removed
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
---
## [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
### Performance
+15 -21
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)
[![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/40f8f1914287dea317122a837f98b0ddf7af3205adc2f84a350d767e0a6a345c)
[![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">
@@ -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" />
</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 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
<p align="center">
<a href="https://t.me/spotiflac">
<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>
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](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)
## FAQ
@@ -87,9 +71,9 @@ A: Some countries have restricted access to certain streaming service APIs. If d
### Want to support SpotiFLAC-Mobile?
_If this software is useful and brings you value, consider supporting the project by buying me a coffee. Your support helps keep development going._
_If this software is useful and brings you value, consider supporting the project. Your support helps keep development going._
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) <a href="https://www.buymeacoffee.com/zarzet" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height: 40px !important;width: 150px !important;" ></a>
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet)
## Disclaimer
@@ -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.
## 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]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
@@ -33,6 +33,7 @@ class MainActivity: FlutterFragmentActivity() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
private val safDirLock = Any()
private var safScanProgress = SafScanProgress()
@Volatile private var safScanCancel = false
@Volatile private var safScanActive = false
@@ -299,27 +300,55 @@ class MainActivity: FlutterFragmentActivity() {
return name.replace(Regex("[\\\\/:*?\"<>|]"), "_").trim()
}
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
if (relativeDir.isBlank()) return current
private fun sanitizeRelativeDir(relativeDir: String): String {
if (relativeDir.isBlank()) return ""
return relativeDir
.split("/")
.map { sanitizeFilename(it) }
.filter { it.isNotBlank() && it != "." && it != ".." }
.joinToString("/")
}
val parts = relativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
current = if (existing != null && existing.isDirectory) {
existing
} else {
current.createDirectory(part) ?: return null
}
private fun ensureDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) {
return DocumentFile.fromTreeUri(this, treeUri)
}
// Synchronize to prevent concurrent downloads from creating duplicate
// directories with (1), (2) suffixes via SAF's auto-rename behavior.
synchronized(safDirLock) {
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
current = if (existing != null && existing.isDirectory) {
existing
} else {
val created = current.createDirectory(part) ?: return null
// SAF may auto-rename to "part (1)" if another thread just created it.
// Re-check: if the created name differs, delete it and use the original.
val createdName = created.name ?: part
if (createdName != part) {
// Another thread won the race; delete the duplicate and use theirs.
created.delete()
current.findFile(part) ?: return null
} else {
created
}
}
}
return current
}
return current
}
private fun findDocumentDir(treeUri: Uri, relativeDir: String): DocumentFile? {
var current = DocumentFile.fromTreeUri(this, treeUri) ?: return null
if (relativeDir.isBlank()) return current
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
if (safeRelativeDir.isBlank()) return current
val parts = relativeDir.split("/").filter { it.isNotBlank() }
val parts = safeRelativeDir.split("/").filter { it.isNotBlank() }
for (part in parts) {
val existing = current.findFile(part)
if (existing == null || !existing.isDirectory) return null
@@ -359,14 +388,21 @@ class MainActivity: FlutterFragmentActivity() {
obj.put("relative_dir", "")
return obj.toString()
}
val safeRelativeDir = sanitizeRelativeDir(relativeDir)
val safeFileName = sanitizeFilename(fileName)
if (safeFileName.isBlank()) {
obj.put("uri", "")
obj.put("relative_dir", "")
return obj.toString()
}
val treeUri = Uri.parse(treeUriStr)
val targetDir = findDocumentDir(treeUri, relativeDir)
val targetDir = findDocumentDir(treeUri, safeRelativeDir)
if (targetDir != null) {
val direct = targetDir.findFile(fileName)
val direct = targetDir.findFile(safeFileName)
if (direct != null && direct.isFile) {
obj.put("uri", direct.uri.toString())
obj.put("relative_dir", relativeDir)
obj.put("relative_dir", safeRelativeDir)
return obj.toString()
}
}
@@ -392,7 +428,7 @@ class MainActivity: FlutterFragmentActivity() {
val childPath = if (path.isBlank()) childName else "$path/$childName"
queue.add(child to childPath)
} else if (child.isFile) {
if (child.name == fileName) {
if (child.name == safeFileName) {
obj.put("uri", child.uri.toString())
obj.put("relative_dir", path)
return obj.toString()
@@ -408,7 +444,7 @@ class MainActivity: FlutterFragmentActivity() {
private fun buildSafFileName(req: JSONObject, outputExt: String): String {
val provided = req.optString("saf_file_name", "")
if (provided.isNotBlank()) return provided
if (provided.isNotBlank()) return sanitizeFilename(provided)
val trackName = req.optString("track_name", "track")
val artistName = req.optString("artist_name", "")
@@ -424,36 +460,159 @@ class MainActivity: FlutterFragmentActivity() {
return obj.toString()
}
private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
val mime = contentResolver.getType(uri)
val nameHint = (
DocumentFile.fromSingleUri(this, uri)?.name
?: uri.lastPathSegment
?: ""
).lowercase(Locale.ROOT)
val extFromName = when {
nameHint.endsWith(".m4a") -> ".m4a"
nameHint.endsWith(".mp3") -> ".mp3"
nameHint.endsWith(".opus") -> ".opus"
nameHint.endsWith(".flac") -> ".flac"
/**
* Detect whether a content URI belongs to the MediaStore provider.
* Samsung One UI may return MediaStore URIs from SAF tree traversal,
* which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission
* instead of SAF tree permission.
*/
private fun isMediaStoreUri(uri: Uri): Boolean {
val authority = uri.authority ?: return false
return authority == "media" ||
authority.startsWith("media.") ||
authority.contains("media")
}
/**
* 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 -> ""
}
val extFromMime = when (mime) {
}
private fun extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a"
"audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus"
"audio/flac" -> ".flac"
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)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
var tempFile: File? = null
var success = false
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
return tempFile.absolutePath
android.util.Log.w(
"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 {
@@ -476,25 +635,33 @@ class MainActivity: FlutterFragmentActivity() {
}
val treeUri = Uri.parse(treeUriStr)
val relativeDir = req.optString("saf_relative_dir", "")
val relativeDir = sanitizeRelativeDir(req.optString("saf_relative_dir", ""))
val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
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)
?: return errorJson("Failed to access SAF directory")
val fileName = buildSafFileName(req, outputExt)
val existing = targetDir.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()
}
val document = existing ?: targetDir.createFile(mimeType, fileName)
val existingFile = targetDir.findFile(fileName)
val document = existingFile ?: targetDir.createFile(mimeType, fileName)
?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -547,9 +714,14 @@ class MainActivity: FlutterFragmentActivity() {
resetSafScanProgress()
safScanCancel = false
safScanActive = true
updateSafScanProgress {
it.currentFile = "Scanning folders..."
}
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val visitedDirUris = mutableSetOf<String>()
var traversalErrors = 0
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "")
@@ -561,22 +733,52 @@ class MainActivity: FlutterFragmentActivity() {
}
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) {
updateSafScanProgress { it.isComplete = true }
return "[]"
}
if (child.isDirectory) {
val childName = child.name ?: continue
val childPath = if (path.isBlank()) childName else "$path/$childName"
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)
try {
if (child.isDirectory) {
val childName = child.name ?: continue
val childPath = if (path.isBlank()) childName else "$path/$childName"
val childUri = child.uri.toString()
if (childUri == dirUri || visitedDirUris.contains(childUri)) {
continue
}
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 +797,7 @@ class MainActivity: FlutterFragmentActivity() {
val results = JSONArray()
var scanned = 0
var errors = 0
var errors = traversalErrors
for ((doc, _) in audioFiles) {
if (safScanCancel) {
@@ -603,14 +805,22 @@ class MainActivity: FlutterFragmentActivity() {
return "[]"
}
val name = doc.name ?: ""
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress {
it.currentFile = name
}
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
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) {
errors++
} else {
@@ -618,7 +828,7 @@ class MainActivity: FlutterFragmentActivity() {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val lastModified = doc.lastModified()
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified)
results.put(obj)
@@ -691,10 +901,15 @@ class MainActivity: FlutterFragmentActivity() {
resetSafScanProgress()
safScanCancel = false
safScanActive = true
updateSafScanProgress {
it.currentFile = "Scanning folders..."
}
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
var traversalErrors = 0
// Collect all audio files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
@@ -713,7 +928,24 @@ class MainActivity: FlutterFragmentActivity() {
}
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) {
updateSafScanProgress { it.isComplete = true }
val result = JSONObject()
@@ -725,24 +957,44 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
if (child.isDirectory) {
val childName = child.name ?: continue
val childPath = if (path.isBlank()) childName else "$path/$childName"
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")) {
try {
if (child.isDirectory) {
val childName = child.name ?: continue
val childPath = if (path.isBlank()) childName else "$path/$childName"
val childUri = child.uri.toString()
if (childUri == dirUri || visitedDirUris.contains(childUri)) {
continue
}
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 lastModified = child.lastModified()
currentUris.add(uriStr)
// Check if file is new or modified
val existingModified = existingFiles[uriStr]
if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified))
val name = child.name ?: continue
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
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 +1024,7 @@ class MainActivity: FlutterFragmentActivity() {
val results = JSONArray()
var scanned = 0
var errors = 0
var errors = traversalErrors
for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) {
@@ -786,14 +1038,22 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString()
}
val name = doc.name ?: ""
val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress {
it.currentFile = name
}
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
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) {
errors++
} else {
@@ -801,9 +1061,10 @@ class MainActivity: FlutterFragmentActivity() {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified)
obj.put("lastModified", lastModified)
obj.put("fileModTime", safeLastModified)
obj.put("lastModified", safeLastModified)
results.put(obj)
} else {
errors++
@@ -1051,20 +1312,11 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"downloadTrack" -> {
"downloadByStrategy" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadTrack(json)
}
}
result.success(response)
}
"downloadWithFallback" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadWithFallback(json)
Gobackend.downloadByStrategy(json)
}
}
result.success(response)
@@ -1240,11 +1492,12 @@ class MainActivity: FlutterFragmentActivity() {
"safCreateFromPath" -> {
val treeUriStr = call.argument<String>("tree_uri") ?: ""
val relativeDir = call.argument<String>("relative_dir") ?: ""
val fileName = call.argument<String>("file_name") ?: ""
val fileName = sanitizeFilename(call.argument<String>("file_name") ?: "")
val mimeType = call.argument<String>("mime_type") ?: "application/octet-stream"
val srcPath = call.argument<String>("src_path") ?: ""
val createdUri = withContext(Dispatchers.IO) {
if (treeUriStr.isBlank()) return@withContext null
if (fileName.isBlank()) return@withContext null
val dir = ensureDocumentDir(Uri.parse(treeUriStr), relativeDir) ?: return@withContext null
val existing = dir.findFile(fileName)
val createdNew = existing == null
@@ -1372,7 +1625,182 @@ class MainActivity: FlutterFragmentActivity() {
"readFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
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<Number>("duration_ms")?.toLong() ?: 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)
}
@@ -1693,15 +2121,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"downloadWithExtensions" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadWithExtensionsJSON(json)
}
}
result.success(response)
}
"enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}"
@@ -2024,7 +2443,22 @@ class MainActivity: FlutterFragmentActivity() {
"readAudioMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
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)
}
+231 -81
View File
@@ -31,6 +31,8 @@ type AmazonDownloader struct {
var (
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`)
amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`)
)
// AfkarXYZResponse is the response from AfkarXYZ API
@@ -43,6 +45,12 @@ type AfkarXYZResponse struct {
} `json:"data"`
}
// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin}
type AmazonStreamResponse struct {
StreamURL string `json:"streamUrl"`
DecryptionKey string `json:"decryptionKey"`
}
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
@@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader {
return globalAmazonDownloader
}
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
// Returns downloadURL, suggested fileName, optional decryptionKey.
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
var lastErr error
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
if attempt > 0 {
@@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st
time.Sleep(delay)
}
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
if err == nil {
return downloadURL, fileName, nil
return downloadURL, fileName, decryptionKey, nil
}
lastErr = err
errStr := err.Error()
errStr := strings.ToLower(err.Error())
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "eof") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
strings.Contains(errStr, "status 429") ||
strings.Contains(errStr, "http 429")
if !isRetryable {
return "", "", err
return "", "", "", err
}
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
}
// doAfkarXYZRequest performs a single request to AfkarXYZ API
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
func normalizeAmazonASIN(candidate string) string {
trimmed := strings.TrimSpace(candidate)
if trimmed == "" {
return ""
}
if decoded, err := url.QueryUnescape(trimmed); err == nil {
trimmed = decoded
}
trimmed = strings.ToUpper(trimmed)
if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 {
trimmed = trimmed[:idx]
}
if amazonASINRegex.MatchString(trimmed) {
return trimmed
}
return ""
}
func extractAmazonASIN(amazonURL string) string {
raw := strings.TrimSpace(amazonURL)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err == nil {
query := parsed.Query()
// Prefer track-level ASIN when URL also contains albumAsin.
for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} {
if asin := normalizeAmazonASIN(query.Get(key)); asin != "" {
return asin
}
}
path := strings.Trim(parsed.Path, "/")
if path != "" {
segments := strings.Split(path, "/")
for i := 0; i < len(segments)-1; i++ {
segment := strings.ToLower(strings.TrimSpace(segments[i]))
if segment == "track" || segment == "tracks" {
if asin := normalizeAmazonASIN(segments[i+1]); asin != "" {
return asin
}
}
}
if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" {
return asin
}
}
}
match := amazonASINFindRegex.FindString(strings.ToUpper(raw))
return normalizeAmazonASIN(match)
}
// doAfkarXYZRequest performs a single request to Amazon API.
// It tries new endpoint first, then falls back to legacy /convert endpoint.
func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) {
asin := extractAmazonASIN(amazonURL)
if asin != "" {
GoLog("[Amazon] Using ASIN: %s\n", asin)
downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin)
if err == nil {
return downloadURL, fileName, decryptKey, nil
}
GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err)
}
return a.doAfkarXYZRequestLegacy(amazonURL)
}
func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err)
return "", "", "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36")
resp, err := a.client.Do(req)
if err != nil {
return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", fmt.Errorf("failed to read response: %w", err)
}
var apiResp AmazonStreamResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", "", fmt.Errorf("failed to decode response: %w", err)
}
if strings.TrimSpace(apiResp.StreamURL) == "" {
return "", "", "", fmt.Errorf("Amazon API returned empty stream URL")
}
fileName := asin + ".m4a"
return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil
}
func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", "", fmt.Errorf("failed to create legacy request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req)
if err != nil {
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err)
return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode)
return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("failed to read response: %w", err)
return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
}
var apiResp AfkarXYZResponse
if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", fmt.Errorf("failed to decode response: %w", err)
return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
}
if !apiResp.Success || apiResp.Data.DirectLink == "" {
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found")
if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
}
fileName := apiResp.Data.FileName
@@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
return apiResp.Data.DirectLink, fileName, nil
return apiResp.Data.DirectLink, fileName, "", nil
}
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
if err != nil {
return "", "", err
return "", "", "", err
}
if decryptionKey != "" {
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
}
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
return downloadURL, fileName, nil
return downloadURL, fileName, decryptionKey, nil
}
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
@@ -233,17 +361,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD
// AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct {
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
FilePath string
BitDepth int
SampleRate int
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
DecryptionKey string
}
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
@@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
// Download using AfkarXYZ API
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
}
@@ -321,7 +450,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputExt := strings.ToLower(filepath.Ext(afkarFileName))
if outputExt == "" {
outputExt = ".flac"
}
filename = sanitizeFilename(filename) + outputExt
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
@@ -352,6 +485,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
actualOutputPath := outputPath
needsDecryption := strings.TrimSpace(decryptionKey) != ""
if needsDecryption {
GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n")
}
// Wait for parallel operations to complete
<-parallelDone
@@ -360,7 +499,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
SetItemFinalizing(req.ItemID)
}
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate
@@ -368,25 +506,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualTitle := req.TrackName
actualArtist := req.ArtistName
if metaErr == nil && existingMeta != nil {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
if !needsDecryption {
existingMeta, metaErr := ReadMetadata(actualOutputPath)
if metaErr == nil && existingMeta != nil {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
actualTrackNum = existingMeta.TrackNumber
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
if existingMeta.Date != "" && req.ReleaseDate == "" {
actualDate = existingMeta.Date
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
}
if existingMeta.Album != "" && req.AlbumName == "" {
actualAlbum = existingMeta.Album
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
}
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
}
if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) {
actualDiscNum = existingMeta.DiscNumber
GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber)
}
if existingMeta.Date != "" && req.ReleaseDate == "" {
actualDate = existingMeta.Date
GoLog("[Amazon] Using release date from file: %s\n", actualDate)
}
if existingMeta.Album != "" && req.AlbumName == "" {
actualAlbum = existingMeta.Album
GoLog("[Amazon] Using album from file: %s\n", actualAlbum)
}
GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n",
existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date)
}
metadata := Metadata{
@@ -409,7 +550,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else {
existingCover, coverErr := ExtractCoverArt(outputPath)
existingCover, coverErr := ExtractCoverArt(actualOutputPath)
if coverErr == nil && len(existingCover) > 0 {
coverData = existingCover
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
@@ -418,11 +559,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
if isSafOutput {
if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
if isFlacOutput {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
}
} else {
GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath))
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
@@ -433,20 +579,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
GoLog("[Amazon] Lyrics embedded successfully\n")
}
} else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput {
GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n")
}
} else if req.EmbedLyrics {
GoLog("[Amazon] No lyrics available from parallel fetch\n")
@@ -456,17 +604,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality := AudioQuality{}
if isSafOutput {
if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
} else {
quality, err = GetAudioQuality(outputPath)
quality, err = GetAudioQuality(actualOutputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
} else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
finalMeta, metaReadErr := ReadMetadata(outputPath)
finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
@@ -478,9 +626,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
// Add to ISRC index for fast duplicate checking
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
// Add to ISRC index for fast duplicate checking.
// When decryption is pending in Flutter, postpone indexing until final file is settled.
if !isSafOutput && !needsDecryption {
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
}
bitDepth := 0
@@ -496,16 +645,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
FilePath: outputPath,
BitDepth: bitDepth,
SampleRate: sampleRate,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
DecryptionKey: decryptionKey,
}, nil
}
+46
View File
@@ -0,0 +1,46 @@
package gobackend
import "testing"
func TestExtractAmazonASIN(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "prefers trackAsin over albumAsin",
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
want: "B0TRACK456",
},
{
name: "extract from tracks path",
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
want: "B0CYQHGWZJ",
},
{
name: "extract from plain query asin",
url: "https://example.com/?asin=B0CYQHGWZJ",
want: "B0CYQHGWZJ",
},
{
name: "fallback regex",
url: "https://example.com/path/B0CYQHGWZJ",
want: "B0CYQHGWZJ",
},
{
name: "invalid url",
url: "https://music.amazon.com/tracks/not-valid",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractAmazonASIN(tt.url)
if got != tt.want {
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
}
})
}
}
+381 -45
View File
@@ -23,6 +23,11 @@ type AudioMetadata struct {
TrackNumber int
DiscNumber int
ISRC string
Lyrics string
Label string
Copyright string
Composer string
Comment string
}
// MP3Quality represents MP3 specific quality info
@@ -38,6 +43,7 @@ type OggQuality struct {
SampleRate int
BitDepth int
Duration int
Bitrate int // estimated bitrate in bps
}
// =============================================================================
@@ -171,6 +177,21 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
metadata.TrackNumber = parseTrackNumber(value)
case "TPA":
metadata.DiscNumber = parseTrackNumber(value)
case "TCM":
metadata.Composer = value
case "TPB":
metadata.Label = value
case "TCR":
metadata.Copyright = value
case "ULT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXX":
desc, userValue := extractUserTextFrame(frameData)
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
}
pos += 6 + frameSize
@@ -277,6 +298,25 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
metadata.DiscNumber = parseTrackNumber(value)
case "TSRC":
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
}
case "USLT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXXX":
desc, userValue := extractUserTextFrame(frameData)
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
}
pos += 10 + frameSize
@@ -339,6 +379,138 @@ 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)
}
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
func extractLyricsFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
rest := data[4:] // skip 3-byte language code
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 ""
}
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
// encoding(1) + description + separator + value.
func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 {
return "", ""
}
encoding := data[0]
payload := data[1:]
var descRaw, valueRaw []byte
switch encoding {
case 1, 2: // UTF-16 variants
for i := 0; i+1 < len(payload); i += 2 {
if payload[i] == 0 && payload[i+1] == 0 {
descRaw = payload[:i]
valueRaw = payload[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
idx := bytes.IndexByte(payload, 0)
if idx >= 0 {
descRaw = payload[:idx]
if idx+1 <= len(payload) {
valueRaw = payload[idx+1:]
}
}
}
if len(valueRaw) == 0 {
return "", ""
}
descFramed := make([]byte, 1+len(descRaw))
descFramed[0] = encoding
copy(descFramed[1:], descRaw)
valueFramed := make([]byte, 1+len(valueRaw))
valueFramed[0] = encoding
copy(valueFramed[1:], valueRaw)
return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed))
}
func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) {
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
return true
default:
return false
}
}
func decodeUTF16(data []byte) string {
if len(data) < 2 {
return ""
@@ -493,50 +665,144 @@ func GetMP3Quality(filePath string) (*MP3Quality, error) {
file.Seek(audioStart, io.SeekStart)
// Find first valid MP3 frame sync
frameHeader := make([]byte, 4)
for i := 0; i < 10000; i++ { // Search first 10KB
var frameStart int64 = -1
for i := 0; i < 10000; i++ {
if _, err := io.ReadFull(file, frameHeader); err != nil {
break
}
if frameHeader[0] == 0xFF && (frameHeader[1]&0xE0) == 0xE0 {
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
quality.BitDepth = 16
if quality.Bitrate > 0 {
audioSize := fileSize - audioStart - 128
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
pos, _ := file.Seek(0, io.SeekCurrent)
frameStart = pos - 4
break
}
file.Seek(-3, io.SeekCurrent)
}
if frameStart < 0 {
return quality, nil
}
version := (frameHeader[1] >> 3) & 0x03
layer := (frameHeader[1] >> 1) & 0x03
bitrateIdx := (frameHeader[2] >> 4) & 0x0F
sampleRateIdx := (frameHeader[2] >> 2) & 0x03
channelMode := (frameHeader[3] >> 6) & 0x03
// Sample rate tables: [version][index]
// version: 0=MPEG2.5, 1=reserved, 2=MPEG2, 3=MPEG1
sampleRates := [][]int{
{11025, 12000, 8000},
{0, 0, 0},
{22050, 24000, 16000},
{44100, 48000, 32000},
}
if version < 4 && sampleRateIdx < 3 {
quality.SampleRate = sampleRates[version][sampleRateIdx]
}
// Bitrate tables for all MPEG versions and layers
// MPEG1 Layer III
if version == 3 && layer == 1 {
bitrates := []int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// MPEG2/2.5 Layer III
if (version == 0 || version == 2) && layer == 1 {
bitrates := []int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}
if bitrateIdx < 16 {
quality.Bitrate = bitrates[bitrateIdx] * 1000
}
}
// Determine samples per frame for duration calculation
samplesPerFrame := 1152 // MPEG1 Layer III
if version == 0 || version == 2 {
samplesPerFrame = 576 // MPEG2/2.5 Layer III
}
// Try to read Xing/VBRI header from the first frame for VBR info
// Xing header offset depends on MPEG version and channel mode
var xingOffset int
if version == 3 { // MPEG1
if channelMode == 3 { // Mono
xingOffset = 17
} else {
xingOffset = 32
}
} else { // MPEG2/2.5
if channelMode == 3 {
xingOffset = 9
} else {
xingOffset = 17
}
}
// Read enough of the first frame to find Xing/VBRI header
xingBuf := make([]byte, 200)
file.Seek(frameStart+4, io.SeekStart)
n, _ := io.ReadFull(file, xingBuf)
xingBuf = xingBuf[:n]
vbrFrames := 0
vbrBytes := int64(0)
isVBR := false
// Check for Xing/Info header
if xingOffset+8 <= n {
tag := string(xingBuf[xingOffset : xingOffset+4])
if tag == "Xing" || tag == "Info" {
flags := binary.BigEndian.Uint32(xingBuf[xingOffset+4 : xingOffset+8])
off := xingOffset + 8
if flags&0x01 != 0 && off+4 <= n { // Frames flag
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[off : off+4]))
off += 4
}
if flags&0x02 != 0 && off+4 <= n { // Bytes flag
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[off : off+4]))
}
if vbrFrames > 0 {
isVBR = true
}
}
}
// Check for VBRI header (always at offset 32 from frame start + 4)
if !isVBR && 36+26 <= n {
if string(xingBuf[32:36]) == "VBRI" {
vbrBytes = int64(binary.BigEndian.Uint32(xingBuf[36+6 : 36+10]))
vbrFrames = int(binary.BigEndian.Uint32(xingBuf[36+10 : 36+14]))
if vbrFrames > 0 {
isVBR = true
}
}
}
if isVBR && vbrFrames > 0 && quality.SampleRate > 0 {
// Accurate duration from total frames
totalSamples := int64(vbrFrames) * int64(samplesPerFrame)
quality.Duration = int(totalSamples / int64(quality.SampleRate))
// Accurate average bitrate
if vbrBytes > 0 && quality.Duration > 0 {
quality.Bitrate = int(vbrBytes * 8 / int64(quality.Duration))
} else if quality.Duration > 0 {
audioSize := fileSize - audioStart
quality.Bitrate = int(audioSize * 8 / int64(quality.Duration))
}
} else if quality.Bitrate > 0 {
// CBR fallback: estimate duration from file size and frame bitrate
audioSize := fileSize - audioStart - 128 // subtract possible ID3v1 tag
if audioSize > 0 {
quality.Duration = int(audioSize * 8 / int64(quality.Bitrate))
}
}
return quality, nil
}
@@ -740,9 +1006,16 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
break
}
if commentLen > 10000 {
remaining := uint32(reader.Len())
if commentLen > remaining {
break
}
// Large comment entries are typically METADATA_BLOCK_PICTURE.
// Skip them so we can continue parsing normal text tags after/before.
if commentLen > 512*1024 {
reader.Seek(int64(commentLen), io.SeekCurrent)
continue
}
comment := make([]byte, commentLen)
if _, err := reader.Read(comment); err != nil {
@@ -779,6 +1052,18 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.DiscNumber = parseTrackNumber(value)
case "ISRC":
metadata.ISRC = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
}
}
}
@@ -791,7 +1076,6 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
defer file.Close()
quality := &OggQuality{}
isOpus := false
packets, err := collectOggPackets(file, 5, 10)
if err != nil && len(packets) == 0 {
@@ -807,15 +1091,17 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
}
}
if streamType == oggStreamOpus {
isOpus = true
isOpus := streamType == oggStreamOpus
var preSkip int
if isOpus {
for _, pkt := range packets {
if len(pkt) >= 19 && string(pkt[0:8]) == "OpusHead" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
if quality.SampleRate == 0 {
quality.SampleRate = 48000
}
quality.BitDepth = 16
preSkip = int(binary.LittleEndian.Uint16(pkt[10:12]))
break
}
}
@@ -823,26 +1109,76 @@ func GetOggQuality(filePath string) (*OggQuality, error) {
for _, pkt := range packets {
if len(pkt) > 29 && pkt[0] == 0x01 && string(pkt[1:7]) == "vorbis" {
quality.SampleRate = int(binary.LittleEndian.Uint32(pkt[12:16]))
quality.BitDepth = 16
break
}
}
}
// Read granule position from the last Ogg page for accurate duration
stat, err := file.Stat()
if err == nil {
// Very rough duration estimate based on file size
// Assume ~128kbps average for Opus, ~160kbps for Vorbis
avgBitrate := 128000
if !isOpus {
avgBitrate = 160000
if err != nil {
return quality, nil
}
fileSize := stat.Size()
granule := readLastOggGranulePosition(file, fileSize)
if granule > 0 {
if isOpus {
// Opus always uses 48kHz granule position internally
totalSamples := granule - int64(preSkip)
if totalSamples > 0 {
quality.Duration = int(totalSamples / 48000)
}
} else if quality.SampleRate > 0 {
quality.Duration = int(granule / int64(quality.SampleRate))
}
quality.Duration = int(stat.Size() * 8 / int64(avgBitrate))
}
// Calculate average bitrate from file size and actual duration
if quality.Duration > 0 {
quality.Bitrate = int(fileSize * 8 / int64(quality.Duration))
}
return quality, nil
}
// readLastOggGranulePosition seeks to the end of the file and scans backwards
// to find the last Ogg page, then reads its granule position (bytes 6-13).
func readLastOggGranulePosition(file *os.File, fileSize int64) int64 {
// Read the last chunk of the file to find the last OggS sync
searchSize := int64(65536)
if searchSize > fileSize {
searchSize = fileSize
}
buf := make([]byte, searchSize)
offset := fileSize - searchSize
if offset < 0 {
offset = 0
}
n, err := file.ReadAt(buf, offset)
if err != nil && n == 0 {
return 0
}
buf = buf[:n]
// Scan backwards for "OggS" magic
lastPageOffset := -1
for i := n - 4; i >= 0; i-- {
if buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S' {
lastPageOffset = i
break
}
}
if lastPageOffset < 0 || lastPageOffset+14 > n {
return 0
}
// Granule position is at bytes 6-13 of the Ogg page header (little-endian int64)
return int64(binary.LittleEndian.Uint64(buf[lastPageOffset+6 : lastPageOffset+14]))
}
// =============================================================================
// ID3v1 Genre List
// =============================================================================
+129 -15
View File
@@ -28,15 +28,23 @@ const (
deezerAPITimeoutMobile = 25 * time.Second
deezerMaxRetries = 2
deezerRetryDelay = 500 * time.Millisecond
deezerMaxSearchCacheEntries = 300
deezerMaxAlbumCacheEntries = 200
deezerMaxArtistCacheEntries = 200
deezerMaxISRCCacheEntries = 4000
deezerCacheCleanupInterval = 5 * time.Minute
)
type DeezerClient struct {
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string
cacheMu sync.RWMutex
httpClient *http.Client
searchCache map[string]*cacheEntry
albumCache map[string]*cacheEntry
artistCache map[string]*cacheEntry
isrcCache map[string]string
cacheMu sync.RWMutex
lastCacheCleanup time.Time
cacheCleanupInterval time.Duration
}
var (
@@ -47,16 +55,111 @@ var (
func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{
httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
isrcCache: make(map[string]string),
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
isrcCache: make(map[string]string),
cacheCleanupInterval: deezerCacheCleanupInterval,
}
})
return deezerClient
}
func (c *DeezerClient) pruneExpiredCacheEntriesLocked(
cache map[string]*cacheEntry,
now time.Time,
) {
for key, entry := range cache {
if entry == nil || now.After(entry.expiresAt) {
delete(cache, key)
}
}
}
func (c *DeezerClient) trimCacheEntriesLocked(
cache map[string]*cacheEntry,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
for len(cache) > maxEntries {
var oldestKey string
var oldestExpiry time.Time
first := true
for key, entry := range cache {
expiry := time.Time{}
if entry != nil {
expiry = entry.expiresAt
}
if first || expiry.Before(oldestExpiry) {
first = false
oldestKey = key
oldestExpiry = expiry
}
}
if oldestKey == "" {
return
}
delete(cache, oldestKey)
}
}
func (c *DeezerClient) trimStringCacheEntriesLocked(
cache map[string]string,
maxEntries int,
) {
if maxEntries <= 0 || len(cache) <= maxEntries {
return
}
toRemove := len(cache) - maxEntries
for key := range cache {
delete(cache, key)
toRemove--
if toRemove <= 0 {
return
}
}
}
func (c *DeezerClient) maybeCleanupCachesLocked(now time.Time) {
periodicCleanupDue := c.cacheCleanupInterval > 0 &&
(c.lastCacheCleanup.IsZero() ||
now.Sub(c.lastCacheCleanup) >= c.cacheCleanupInterval)
if periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
c.lastCacheCleanup = now
}
if len(c.searchCache) > deezerMaxSearchCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.searchCache, now)
}
c.trimCacheEntriesLocked(c.searchCache, deezerMaxSearchCacheEntries)
}
if len(c.albumCache) > deezerMaxAlbumCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.albumCache, now)
}
c.trimCacheEntriesLocked(c.albumCache, deezerMaxAlbumCacheEntries)
}
if len(c.artistCache) > deezerMaxArtistCacheEntries {
if !periodicCleanupDue {
c.pruneExpiredCacheEntriesLocked(c.artistCache, now)
}
c.trimCacheEntriesLocked(c.artistCache, deezerMaxArtistCacheEntries)
}
if len(c.isrcCache) > deezerMaxISRCCacheEntries {
c.trimStringCacheEntriesLocked(c.isrcCache, deezerMaxISRCCacheEntries)
}
}
type deezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
@@ -414,10 +517,12 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists, %d albums, %d playlists\n", len(result.Tracks), len(result.Artists), len(result.Albums), len(result.Playlists))
c.cacheMu.Lock()
now := time.Now()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
@@ -555,10 +660,12 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
}
c.cacheMu.Lock()
now := time.Now()
c.albumCache[albumID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
@@ -638,10 +745,12 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
}
c.cacheMu.Lock()
now := time.Now()
c.artistCache[artistID] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
return result, nil
@@ -807,6 +916,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
for trackIDStr, isrc := range directISRCs {
c.isrcCache[trackIDStr] = isrc
}
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
}
@@ -841,6 +951,7 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
c.cacheMu.Lock()
c.isrcCache[trackIDStr] = fullTrack.ISRC
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
}(track)
}
@@ -864,6 +975,7 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
c.cacheMu.Lock()
c.isrcCache[trackID] = fullTrack.ISRC
c.maybeCleanupCachesLocked(time.Now())
c.cacheMu.Unlock()
return fullTrack.ISRC, nil
@@ -946,10 +1058,12 @@ func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID str
}
c.cacheMu.Lock()
now := time.Now()
c.searchCache[cacheKey] = &cacheEntry{
data: result,
expiresAt: time.Now().Add(deezerCacheTTL),
expiresAt: now.Add(deezerCacheTTL),
}
c.maybeCleanupCachesLocked(now)
c.cacheMu.Unlock()
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
+1036 -156
View File
File diff suppressed because it is too large Load Diff
+37 -19
View File
@@ -1082,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil {
result = DownloadResult{
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title,
Artist: amazonResult.Artist,
Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
}
}
err = amazonErr
@@ -1119,6 +1121,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
}, nil
}
@@ -1164,16 +1168,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
optionsJSON, _ := json.Marshal(options)
if options == nil {
options = map[string]interface{}{}
}
script := fmt.Sprintf(`
// Avoid embedding user input directly into JS source. Some inputs can trigger
// parser/runtime edge cases on specific devices/Goja builds.
const queryVar = "__sf_custom_search_query"
const optionsVar = "__sf_custom_search_options"
global := p.vm.GlobalObject()
_ = global.Set(queryVar, query)
_ = global.Set(optionsVar, options)
defer func() {
global.Delete(queryVar)
global.Delete(optionsVar)
}()
const script = `
(function() {
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
return extension.customSearch(%q, %s);
return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
}
return null;
})()
`, query, string(optionsJSON))
`
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil {
@@ -1358,12 +1376,12 @@ type PostProcessResult struct {
}
type PostProcessInput struct {
Path string `json:"path,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
Path string `json:"path,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"`
IsSAF bool `json:"is_saf,omitempty"`
Size int64 `json:"size,omitempty"`
IsSAF bool `json:"is_saf,omitempty"`
}
const PostProcessTimeout = 2 * time.Minute
+58 -4
View File
@@ -18,6 +18,43 @@ import (
// ==================== Auth API (OAuth Support) ====================
func validateExtensionAuthURL(urlStr string) error {
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid auth URL: %w", err)
}
if parsed.Scheme != "https" {
return fmt.Errorf("invalid auth URL: only https is allowed")
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("invalid auth URL: hostname is required")
}
if parsed.User != nil {
return fmt.Errorf("invalid auth URL: embedded credentials are not allowed")
}
if isPrivateIP(host) {
return fmt.Errorf("invalid auth URL: private/local network is not allowed")
}
return nil
}
func summarizeURLForLog(urlStr string) string {
parsed, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
if parsed.Host == "" {
return parsed.Scheme + "://"
}
return fmt.Sprintf("%s://%s%s", parsed.Scheme, parsed.Host, parsed.Path)
}
func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
if len(call.Arguments) < 1 {
return r.vm.ToValue(map[string]interface{}{
@@ -32,6 +69,13 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
callbackURL = call.Arguments[1].String()
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
pendingAuthRequestsMu.Lock()
pendingAuthRequests[r.extensionID] = &PendingAuthRequest{
ExtensionID: r.extensionID,
@@ -50,7 +94,7 @@ func (r *ExtensionRuntime) authOpenUrl(call goja.FunctionCall) goja.Value {
state.AuthCode = ""
extensionAuthStateMu.Unlock()
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, authURL)
GoLog("[Extension:%s] Auth URL requested: %s\n", r.extensionID, summarizeURLForLog(authURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
@@ -273,6 +317,12 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
"error": "authUrl, clientId, and redirectUri are required",
})
}
if err := validateExtensionAuthURL(authURL); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": err.Error(),
})
}
scope, _ := config["scope"].(string)
extraParams, _ := config["extraParams"].(map[string]interface{})
@@ -331,7 +381,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V
}
pendingAuthRequestsMu.Unlock()
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, fullAuthURL)
GoLog("[Extension:%s] PKCE OAuth started: %s\n", r.extensionID, summarizeURLForLog(fullAuthURL))
return r.vm.ToValue(map[string]interface{}{
"success": true,
@@ -441,13 +491,17 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
"error": err.Error(),
})
}
bodyPreview := sanitizeSensitiveLogText(string(body))
if len(bodyPreview) > 1000 {
bodyPreview = bodyPreview[:1000] + "...[truncated]"
}
var tokenResp map[string]interface{}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": fmt.Sprintf("failed to parse token response: %v", err),
"body": string(body),
"body": bodyPreview,
})
}
@@ -468,7 +522,7 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja
return r.vm.ToValue(map[string]interface{}{
"success": false,
"error": "no access_token in response",
"body": string(body),
"body": bodyPreview,
})
}
+3
View File
@@ -32,6 +32,9 @@ func (r *ExtensionRuntime) validateDomain(urlStr string) error {
if parsed.Scheme != "https" {
return fmt.Errorf("network access denied: only https is allowed")
}
if parsed.User != nil {
return fmt.Errorf("invalid URL: embedded credentials are not allowed")
}
domain := parsed.Hostname()
if domain == "" {
+1 -1
View File
@@ -46,7 +46,7 @@ func (r *ExtensionRuntime) saveStorage(storage map[string]interface{}) error {
return err
}
return os.WriteFile(storagePath, data, 0644)
return os.WriteFile(storagePath, data, 0600)
}
func (r *ExtensionRuntime) storageGet(call goja.FunctionCall) goja.Value {
+2
View File
@@ -4,6 +4,7 @@ package gobackend
import (
"context"
"fmt"
"runtime/debug"
"sync"
"time"
@@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
IsTimeout: true,
}}
} else {
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
}
}
+8 -11
View File
@@ -2,19 +2,16 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.25.7
toolchain go1.26.0
require (
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/flacvorbis v0.2.0
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/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
golang.org/x/net v0.49.0
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af
golang.org/x/net v0.50.0
)
require (
@@ -23,10 +20,10 @@ require (
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/klauspost/compress v1.17.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
)
+25 -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/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
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/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
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 v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
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 v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
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/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
@@ -23,25 +22,43 @@ 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/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
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/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k=
golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
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,
}
// 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{
Transport: sharedTransport,
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 {
return sharedClient
}
@@ -82,6 +112,7 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
}
// Also checks for ISP blocking on errors
+9 -2
View File
@@ -28,6 +28,7 @@ type LibraryScanResult struct {
ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"`
}
@@ -289,8 +290,11 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetMP3Quality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
@@ -326,8 +330,11 @@ func scanOggFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
quality, err := GetOggQuality(filePath)
if err == nil {
result.SampleRate = quality.SampleRate
result.BitDepth = quality.BitDepth
result.BitDepth = quality.BitDepth // 0 for lossy
result.Duration = quality.Duration
if quality.Bitrate > 0 {
result.Bitrate = quality.Bitrate / 1000 // convert bps to kbps
}
}
if result.TrackName == "" {
+16
View File
@@ -3,6 +3,7 @@ package gobackend
import (
"encoding/json"
"fmt"
"regexp"
"strings"
"sync"
"time"
@@ -30,8 +31,22 @@ const (
var (
globalLogBuffer *LogBuffer
logBufferOnce sync.Once
authorizationBearerPattern = regexp.MustCompile(`(?i)\bAuthorization\b\s*[:=]\s*Bearer\s+[A-Za-z0-9._~+/\-]+=*`)
genericKeyValuePattern = regexp.MustCompile(`(?i)\b(access[_\s-]?token|refresh[_\s-]?token|id[_\s-]?token|client[_\s-]?secret|authorization|password|api[_\s-]?key)\b(\s*[:=]\s*)([^\s,;]+)`)
queryTokenPattern = regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|id_token|token|client_secret|api_key|apikey|password)=)[^&\s]+`)
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/\-]+=*`)
)
func sanitizeSensitiveLogText(message string) string {
redacted := message
redacted = authorizationBearerPattern.ReplaceAllString(redacted, "Authorization: Bearer [REDACTED]")
redacted = genericKeyValuePattern.ReplaceAllString(redacted, `${1}${2}[REDACTED]`)
redacted = queryTokenPattern.ReplaceAllString(redacted, `${1}[REDACTED]`)
redacted = bearerTokenPattern.ReplaceAllString(redacted, "Bearer [REDACTED]")
return redacted
}
func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{
@@ -71,6 +86,7 @@ func (lb *LogBuffer) Add(level, tag, message string) {
return
}
message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message)
entry := LogEntry{
+187 -38
View File
@@ -4,16 +4,97 @@ import (
"bytes"
"encoding/binary"
"fmt"
stdimage "image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
"github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
)
func detectCoverMIME(coverPath string, coverData []byte) string {
// Prefer magic-byte detection over file extension.
// Some providers return non-JPEG data behind .jpg URLs.
if len(coverData) >= 8 &&
coverData[0] == 0x89 &&
coverData[1] == 0x50 &&
coverData[2] == 0x4E &&
coverData[3] == 0x47 &&
coverData[4] == 0x0D &&
coverData[5] == 0x0A &&
coverData[6] == 0x1A &&
coverData[7] == 0x0A {
return "image/png"
}
if len(coverData) >= 3 &&
coverData[0] == 0xFF &&
coverData[1] == 0xD8 &&
coverData[2] == 0xFF {
return "image/jpeg"
}
if len(coverData) >= 6 {
header := string(coverData[:6])
if header == "GIF87a" || header == "GIF89a" {
return "image/gif"
}
}
if len(coverData) >= 12 &&
string(coverData[:4]) == "RIFF" &&
string(coverData[8:12]) == "WEBP" {
return "image/webp"
}
switch strings.ToLower(filepath.Ext(strings.TrimSpace(coverPath))) {
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
}
return "image/jpeg"
}
func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock, error) {
if len(coverData) == 0 {
return flac.MetaDataBlock{}, fmt.Errorf("empty cover data")
}
mime := detectCoverMIME(coverPath, coverData)
picture := &flacpicture.MetadataBlockPicture{
PictureType: flacpicture.PictureTypeFrontCover,
MIME: mime,
Description: "Front Cover",
ImageData: coverData,
}
// Width/height/depth are optional in practice; keep zero when decode fails.
if cfg, format, err := stdimage.DecodeConfig(bytes.NewReader(coverData)); err == nil {
picture.Width = uint32(cfg.Width)
picture.Height = uint32(cfg.Height)
switch format {
case "png":
picture.ColorDepth = 32
case "jpeg":
picture.ColorDepth = 24
default:
picture.ColorDepth = 0
}
}
return picture.Marshal(), nil
}
type Metadata struct {
Title string
Artist string
@@ -29,6 +110,8 @@ type Metadata struct {
Genre string
Label string
Copyright string
Composer string
Comment string
}
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
@@ -98,6 +181,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -117,19 +208,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
}
}
picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Front Cover",
coverData,
"image/jpeg",
)
picBlock, err := buildPictureBlock(coverPath, coverData)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
} else {
picBlock := picture.Marshal()
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
return fmt.Errorf("failed to create picture block: %w", err)
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
} else {
fmt.Printf("[Metadata] Warning: Cover file does not exist: %s\n", coverPath)
@@ -206,6 +290,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -220,19 +312,12 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
}
}
picture, err := flacpicture.NewFromImageData(
flacpicture.PictureTypeFrontCover,
"Front Cover",
coverData,
"image/jpeg",
)
picBlock, err := buildPictureBlock("", coverData)
if err != nil {
fmt.Printf("[Metadata] Warning: Failed to create picture block: %v\n", err)
} else {
picBlock := picture.Marshal()
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
return fmt.Errorf("failed to create picture block: %w", err)
}
f.Meta = append(f.Meta, &picBlock)
fmt.Printf("[Metadata] Cover art embedded successfully (%d bytes)\n", len(coverData))
}
return f.Save(filePath)
@@ -292,6 +377,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
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
}
}
@@ -451,33 +542,91 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
}
func ExtractLyrics(filePath string) (string, error) {
lower := strings.ToLower(filePath)
if strings.HasSuffix(lower, ".flac") {
return extractLyricsFromFlac(filePath)
}
if strings.HasSuffix(lower, ".mp3") {
meta, err := ReadID3Tags(filePath)
if err != nil || meta == nil {
return "", fmt.Errorf("no lyrics found in file")
}
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
return "", fmt.Errorf("no lyrics found in file")
}
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
meta, err := ReadOggVorbisComments(filePath)
if err != nil || meta == nil {
return "", fmt.Errorf("no lyrics found in file")
}
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
return "", fmt.Errorf("no lyrics found in file")
}
return "", fmt.Errorf("unsupported file format for lyrics extraction")
}
func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, meta := range f.Meta {
if meta.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
if meta.Type != flac.VorbisComment {
continue
}
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err != nil {
continue
}
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
return lyrics[0], nil
}
lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
return lyrics[0], nil
}
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
return lyrics[0], nil
}
}
return "", fmt.Errorf("no lyrics found in file")
}
func looksLikeEmbeddedLyrics(value string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return false
}
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
return true
}
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
return true
}
return false
}
type AudioQuality struct {
BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"`
+3 -1
View File
@@ -419,7 +419,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality)
region := "US"
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n")
@@ -428,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
if err != nil {
return "", err
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
resp, err := client.Do(req)
if err != nil {
+80
View File
@@ -0,0 +1,80 @@
package gobackend
import (
"path/filepath"
"strings"
"testing"
)
func TestSanitizeSensitiveLogText(t *testing.T) {
input := "access_token=abc123 Authorization:Bearer xyz456 https://api.example.com/cb?refresh_token=zzz"
redacted := sanitizeSensitiveLogText(input)
if strings.Contains(redacted, "abc123") || strings.Contains(redacted, "xyz456") || strings.Contains(redacted, "zzz") {
t.Fatalf("expected sensitive values to be redacted, got: %s", redacted)
}
if !strings.Contains(redacted, "[REDACTED]") {
t.Fatalf("expected redaction marker in output, got: %s", redacted)
}
}
func TestValidateExtensionAuthURL(t *testing.T) {
if err := validateExtensionAuthURL("https://accounts.example.com/oauth/authorize"); err != nil {
t.Fatalf("expected valid auth URL, got error: %v", err)
}
blocked := []string{
"http://accounts.example.com/oauth/authorize",
"https://user:pass@accounts.example.com/oauth/authorize",
"https://localhost/oauth/authorize",
}
for _, rawURL := range blocked {
if err := validateExtensionAuthURL(rawURL); err == nil {
t.Fatalf("expected URL to be blocked: %s", rawURL)
}
}
}
func TestValidateDomainRejectsEmbeddedCredentials(t *testing.T) {
ext := &LoadedExtension{
ID: "test-ext",
Manifest: &ExtensionManifest{
Name: "test-ext",
Permissions: ExtensionPermissions{
Network: []string{"api.example.com"},
},
},
DataDir: t.TempDir(),
}
runtime := NewExtensionRuntime(ext)
if err := runtime.validateDomain("https://user:pass@api.example.com/resource"); err == nil {
t.Fatal("expected embedded URL credentials to be rejected")
}
}
func TestBuildStoreExtensionDestPath(t *testing.T) {
baseDir := t.TempDir()
destPath, err := buildStoreExtensionDestPath(baseDir, "../evil/name")
if err != nil {
t.Fatalf("expected sanitized path to be generated, got error: %v", err)
}
if !isPathWithinBase(baseDir, destPath) {
t.Fatalf("expected destination path to remain under base dir: %s", destPath)
}
baseName := filepath.Base(destPath)
if strings.Contains(baseName, "/") || strings.Contains(baseName, `\`) {
t.Fatalf("expected filename to be sanitized, got: %s", baseName)
}
if !strings.HasSuffix(baseName, ".spotiflac-ext") {
t.Fatalf("expected .spotiflac-ext suffix, got: %s", baseName)
}
if _, err := buildStoreExtensionDestPath(baseDir, " "); err == nil {
t.Fatal("expected empty extension id to be rejected")
}
}
+144 -13
View File
@@ -15,18 +15,21 @@ type SongLinkClient struct {
}
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
YouTube bool `json:"youtube"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
YouTubeURL string `json:"youtube_url,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 (
@@ -37,7 +40,7 @@ var (
func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout),
client: NewMetadataHTTPClient(SongLinkTimeout),
}
})
return globalSongLinkClient
@@ -119,6 +122,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
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
}
@@ -246,6 +265,52 @@ func extractTidalIDFromURL(tidalURL string) string {
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
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
@@ -261,6 +326,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
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
type AlbumAvailability struct {
SpotifyID string `json:"spotify_id"`
@@ -441,6 +520,19 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
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
}
@@ -528,6 +620,19 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
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
}
@@ -584,6 +689,20 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
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) {
songLinkRateLimiter.WaitForSlot()
@@ -652,6 +771,18 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
availability.DeezerURL = 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
}
+80
View File
@@ -0,0 +1,80 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
// This is used as a fallback when direct Spotify API access is blocked/limited.
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL)
if err != nil {
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
}
base := strings.TrimSpace(apiBaseURL)
if base == "" {
base = DefaultSpotFetchAPIBaseURL
}
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
}
switch parsed.Type {
case "track":
var trackResp TrackResponse
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
return nil, fmt.Errorf("failed to decode track response: %w", err)
}
return trackResp, nil
case "album":
var albumResp AlbumResponsePayload
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
return nil, fmt.Errorf("failed to decode album response: %w", err)
}
return &albumResp, nil
case "playlist":
var playlistResp PlaylistResponsePayload
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
}
return playlistResp, nil
case "artist":
var artistResp ArtistResponsePayload
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
return nil, fmt.Errorf("failed to decode artist response: %w", err)
}
return &artistResp, nil
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
httpClient: NewMetadataHTTPClient(15 * time.Second),
clientID: clientID,
clientSecret: clientSecret,
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
}
+5
View File
@@ -46,6 +46,11 @@ post_install do |installer|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
definitions = config.build_settings['GCC_PREPROCESSOR_DEFINITIONS']
unless definitions.include?('PERMISSION_NOTIFICATIONS=1')
definitions << 'PERMISSION_NOTIFICATIONS=1'
end
end
end
end
+52 -15
View File
@@ -83,18 +83,12 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "downloadTrack":
case "downloadByStrategy":
let requestJson = call.arguments as! String
let response = GobackendDownloadTrack(requestJson, &error)
let response = GobackendDownloadByStrategy(requestJson, &error)
if let error = error { throw error }
return response
case "downloadWithFallback":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithFallback(requestJson, &error)
if let error = error { throw error }
return response
case "getDownloadProgress":
let response = GobackendGetDownloadProgress()
return response
@@ -209,6 +203,41 @@ import Gobackend // Import Go framework
case "cleanupConnections":
GobackendCleanupConnections()
return nil
case "downloadCoverToFile":
let args = call.arguments as! [String: Any]
let coverURL = args["cover_url"] as! String
let outputPath = args["output_path"] as! String
let maxQuality = args["max_quality"] as? Bool ?? true
GobackendDownloadCoverToFile(coverURL, outputPath, maxQuality, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "extractCoverToFile":
let args = call.arguments as! [String: Any]
let audioPath = args["audio_path"] as! String
let outputPath = args["output_path"] as! String
GobackendExtractCoverToFile(audioPath, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "fetchAndSaveLyrics":
let args = call.arguments as! [String: Any]
let trackName = args["track_name"] as! String
let artistName = args["artist_name"] as! String
let spotifyId = args["spotify_id"] as! String
let durationMs = args["duration_ms"] as? Int64 ?? 0
let outputPath = args["output_path"] as! String
GobackendFetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath, &error)
if let error = error { throw error }
return "{\"success\":true}"
case "reEnrichFile":
let args = call.arguments as! [String: Any]
let requestJson = args["request_json"] as? String ?? "{}"
let response = GobackendReEnrichFile(requestJson, &error)
if let error = error { throw error }
return response
case "readFileMetadata":
let args = call.arguments as! [String: Any]
@@ -217,6 +246,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
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":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
@@ -471,12 +508,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "enrichTrackWithExtension":
let args = call.arguments as! [String: Any]
let extensionId = args["extension_id"] as! String
@@ -484,6 +515,12 @@ import Gobackend // Import Go framework
let response = GobackendEnrichTrackWithExtensionJSON(extensionId, trackJson, &error)
if let error = error { throw error }
return response
case "downloadWithExtensions":
let requestJson = call.arguments as! String
let response = GobackendDownloadWithExtensionsJSON(requestJson, &error)
if let error = error { throw error }
return response
case "removeExtension":
let args = call.arguments as! [String: Any]
+2 -3
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.5.1';
static const String buildNumber = '75';
static const String version = '3.6.6';
static const String buildNumber = '80';
static const String fullVersion = '$version+$buildNumber';
@@ -17,6 +17,5 @@ class AppInfo {
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
}
+466 -12
View File
@@ -712,6 +712,12 @@ abstract class AppLocalizations {
/// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'**
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
///
/// In en, this message translates to:
@@ -922,18 +928,6 @@ abstract class AppLocalizations {
/// **'Support'**
String get aboutSupport;
/// Donation link
///
/// In en, this message translates to:
/// **'Buy me a coffee'**
String get aboutBuyMeCoffee;
/// Subtitle for donation
///
/// In en, this message translates to:
/// **'Support development on Ko-fi'**
String get aboutBuyMeCoffeeSubtitle;
/// Section for app info
///
/// In en, this message translates to:
@@ -988,6 +982,18 @@ abstract class AppLocalizations {
/// **'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'**
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
///
/// In en, this message translates to:
@@ -3484,6 +3490,12 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'**
String get qualityNote;
/// Note for YouTube service explaining lossy-only quality
///
/// In en, this message translates to:
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Setting - show quality picker
///
/// In en, this message translates to:
@@ -3508,6 +3520,42 @@ abstract class AppLocalizations {
/// **'Album Folder Structure'**
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 - strip featured artists from folder name
///
/// In en, this message translates to:
/// **'Primary artist only for folders'**
String get downloadUsePrimaryArtistOnly;
/// Subtitle when primary artist only is enabled
///
/// In en, this message translates to:
/// **'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)'**
String get downloadUsePrimaryArtistOnlyEnabled;
/// Subtitle when primary artist only is disabled
///
/// In en, this message translates to:
/// **'Full artist string used for folder name'**
String get downloadUsePrimaryArtistOnlyDisabled;
/// Setting - output file format
///
/// In en, this message translates to:
@@ -3922,6 +3970,18 @@ abstract class AppLocalizations {
/// **'Playlist'**
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
///
/// In en, this message translates to:
@@ -4090,6 +4150,18 @@ abstract class AppLocalizations {
/// **'Scan music & detect duplicates'**
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
///
/// In en, this message translates to:
@@ -4396,6 +4468,24 @@ abstract class AppLocalizations {
/// **'This Year'**
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
///
/// In en, this message translates to:
@@ -4755,6 +4845,370 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'No orphaned entries found'**
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;
/// Snackbar while saving lyrics to file
///
/// In en, this message translates to:
/// **'Saving lyrics...'**
String get trackSaveLyricsProgress;
/// 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);
/// Menu item - convert audio format
///
/// In en, this message translates to:
/// **'Convert Format'**
String get trackConvertFormat;
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3 or Opus'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
///
/// In en, this message translates to:
/// **'Convert Audio'**
String get trackConvertTitle;
/// Label for format selection
///
/// In en, this message translates to:
/// **'Target Format'**
String get trackConvertTargetFormat;
/// Label for bitrate selection
///
/// In en, this message translates to:
/// **'Bitrate'**
String get trackConvertBitrate;
/// Confirmation dialog title
///
/// In en, this message translates to:
/// **'Confirm Conversion'**
String get trackConvertConfirmTitle;
/// Confirmation dialog message
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.'**
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
);
/// Snackbar while converting
///
/// In en, this message translates to:
/// **'Converting audio...'**
String get trackConvertConverting;
/// Snackbar after successful conversion
///
/// In en, this message translates to:
/// **'Converted to {format} successfully'**
String trackConvertSuccess(String format);
/// Snackbar when conversion fails
///
/// In en, this message translates to:
/// **'Conversion failed'**
String get trackConvertFailed;
}
class _AppLocalizationsDelegate
+278 -7
View File
@@ -352,6 +352,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Erweiterungen';
@@ -465,13 +469,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Spendiere mir einen Kaffee';
@override
String get aboutBuyMeCoffeeSubtitle =>
'Unterstütze die Entwicklung auf Ko-fi';
@override
String get aboutApp => 'App';
@@ -504,6 +501,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
@@ -1922,6 +1926,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1934,6 +1942,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2169,6 +2199,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2275,6 +2311,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2442,6 +2484,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2678,4 +2729,224 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+278 -6
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Extensions';
@@ -453,12 +457,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -491,6 +489,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1912,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1928,28 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2185,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2297,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2470,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2663,4 +2715,224 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+278 -12
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Extensions';
@@ -453,12 +457,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -491,6 +489,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1912,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1928,28 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2185,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2297,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2470,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2663,6 +2715,226 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).
@@ -3101,12 +3373,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs {
@override
String get aboutSupport => 'Soporte';
@override
String get aboutBuyMeCoffee => 'Invítame a un café';
@override
String get aboutBuyMeCoffeeSubtitle => 'Apoyar el desarrollo en Ko-fi';
@override
String get aboutApp => 'Aplicación';
+278 -6
View File
@@ -343,6 +343,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Extensions';
@@ -453,12 +457,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -491,6 +489,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1912,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1928,28 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2185,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2297,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2470,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2663,4 +2715,224 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+278 -6
View File
@@ -343,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Extensions';
@@ -453,12 +457,6 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -491,6 +489,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1912,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1928,28 @@ class AppLocalizationsHi extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2185,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2297,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2470,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2663,4 +2715,224 @@ class AppLocalizationsHi extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
File diff suppressed because it is too large Load Diff
+278 -6
View File
@@ -340,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => '拡張';
@@ -449,12 +453,6 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get aboutSupport => 'サポート';
@override
String get aboutBuyMeCoffee => 'コーヒーを買ってください';
@override
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi で開発をサポートします';
@override
String get aboutApp => 'アプリ';
@@ -487,6 +485,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
@@ -1895,6 +1900,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1907,6 +1916,28 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => '形式を保存';
@@ -2140,6 +2171,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get recentTypePlaylist => 'プレイリスト';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'プレイリスト: $name';
@@ -2246,6 +2283,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2413,6 +2456,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2649,4 +2701,224 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+278 -6
View File
@@ -343,6 +343,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Extensions';
@@ -453,12 +457,6 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -491,6 +489,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1912,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1928,28 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2185,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2297,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2470,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2663,4 +2715,224 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+278 -6
View File
@@ -343,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Extensions';
@@ -453,12 +457,6 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -491,6 +489,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1912,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1928,28 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2185,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2297,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2470,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2663,4 +2715,224 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+278 -12
View File
@@ -343,6 +343,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Extensions';
@@ -453,12 +457,6 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -491,6 +489,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1912,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1928,28 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2185,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2297,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2470,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2663,6 +2715,226 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).
@@ -3100,12 +3372,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt {
@override
String get aboutSupport => 'Apoiar';
@override
String get aboutBuyMeCoffee => 'Compre-me um café';
@override
String get aboutBuyMeCoffeeSubtitle => 'Apoie o desenvolvimento na Ko-fi';
@override
String get aboutApp => 'Aplicativo';
+278 -6
View File
@@ -354,6 +354,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Расширения';
@@ -466,12 +470,6 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get aboutSupport => 'Поддержка';
@override
String get aboutBuyMeCoffee => 'Купить мне кофе';
@override
String get aboutBuyMeCoffeeSubtitle => 'Поддержать разработку на Ko-fi';
@override
String get aboutApp => 'Приложение';
@@ -504,6 +502,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutDabMusicDesc =>
'Лучший 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
String get aboutAppDescription =>
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
@@ -1945,6 +1950,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1957,6 +1966,28 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Формат сохранения';
@@ -2199,6 +2230,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get recentTypePlaylist => 'Плейлист';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Плейлист: $name';
@@ -2306,6 +2343,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2473,6 +2516,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2709,4 +2761,224 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+278 -6
View File
@@ -348,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Eklentiler';
@@ -460,12 +464,6 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get aboutSupport => 'Destek';
@override
String get aboutBuyMeCoffee => 'Bana bir kahve ısmarla';
@override
String get aboutBuyMeCoffeeSubtitle => 'Ko-fi üzerinden uygulamayı destekle';
@override
String get aboutApp => 'Uygulama';
@@ -498,6 +496,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
@@ -1922,6 +1927,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1934,6 +1943,28 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2169,6 +2200,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2275,6 +2312,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2442,6 +2485,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2678,4 +2730,224 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
+278 -18
View File
@@ -343,6 +343,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsSpotifyWarning =>
'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
String get extensionsTitle => 'Extensions';
@@ -453,12 +457,6 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -491,6 +489,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get aboutDabMusicDesc =>
'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
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1912,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1928,28 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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
String get downloadUsePrimaryArtistOnly => 'Primary artist only for folders';
@override
String get downloadUsePrimaryArtistOnlyEnabled =>
'Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)';
@override
String get downloadUsePrimaryArtistOnlyDisabled =>
'Full artist string used for folder name';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2185,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2297,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2470,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2663,6 +2715,226 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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 trackSaveLyricsProgress => 'Saving lyrics...';
@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';
}
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
}
/// The translations for Chinese, as used in China (`zh_CN`).
@@ -3111,12 +3383,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
@@ -5257,12 +5523,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh {
@override
String get aboutSupport => 'Support';
@override
String get aboutBuyMeCoffee => 'Buy me a coffee';
@override
String get aboutBuyMeCoffeeSubtitle => 'Support development on Ko-fi';
@override
String get aboutApp => 'App';
+1246 -242
View File
File diff suppressed because it is too large Load Diff
+224 -5
View File
@@ -241,6 +241,8 @@
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"@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": {"description": "Extensions page title"},
@@ -324,10 +326,6 @@
"@aboutSocial": {"description": "Section for social links"},
"aboutSupport": "Support",
"@aboutSupport": {"description": "Section for support/donation links"},
"aboutBuyMeCoffee": "Buy me a coffee",
"@aboutBuyMeCoffee": {"description": "Donation link"},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {"description": "Subtitle for donation"},
"aboutApp": "App",
"@aboutApp": {"description": "Section for app info"},
"aboutVersion": "Version",
@@ -346,6 +344,10 @@
"@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": {"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": {"description": "App description in header card"},
@@ -1408,6 +1410,8 @@
"@lossyFormatOpusSubtitle": {"description": "Opus format description"},
"qualityNote": "Actual quality depends on track availability from the service",
"@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": {"description": "Setting - show quality picker"},
@@ -1417,6 +1421,18 @@
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
"downloadAlbumFolderStructure": "Album Folder Structure",
"@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"},
"downloadUsePrimaryArtistOnly": "Primary artist only for folders",
"@downloadUsePrimaryArtistOnly": {"description": "Setting - strip featured artists from folder name"},
"downloadUsePrimaryArtistOnlyEnabled": "Featured artists removed from folder name (e.g. Justin Bieber, Quavo → Justin Bieber)",
"@downloadUsePrimaryArtistOnlyEnabled": {"description": "Subtitle when primary artist only is enabled"},
"downloadUsePrimaryArtistOnlyDisabled": "Full artist string used for folder name",
"@downloadUsePrimaryArtistOnlyDisabled": {"description": "Subtitle when primary artist only is disabled"},
"downloadSaveFormat": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service",
@@ -1590,6 +1606,12 @@
"@recentTypeSong": {"description": "Recent access item type - song/track"},
"recentTypePlaylist": "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": {
@@ -1700,6 +1722,10 @@
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"@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": {"description": "Library settings page title"},
"libraryStatus": "Library Status",
@@ -1824,6 +1850,12 @@
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
"libraryFilterDateYear": "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": {
"description": "Badge showing number of active filters",
@@ -2000,5 +2032,192 @@
}
},
"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"},
"trackSaveLyricsProgress": "Saving lyrics...",
"@trackSaveLyricsProgress": {"description": "Snackbar while saving lyrics to file"},
"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"}
}
},
"trackConvertFormat": "Convert Format",
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
"trackConvertTitle": "Convert Audio",
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
"trackConvertTargetFormat": "Target Format",
"@trackConvertTargetFormat": {"description": "Label for format selection"},
"trackConvertBitrate": "Bitrate",
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
"trackConvertConfirmTitle": "Confirm Conversion",
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
"@trackConvertConfirmMessage": {
"description": "Confirmation dialog message",
"placeholders": {
"sourceFormat": {"type": "String"},
"targetFormat": {"type": "String"},
"bitrate": {"type": "String"}
}
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {"description": "Snackbar while converting"},
"trackConvertSuccess": "Converted to {format} successfully",
"@trackConvertSuccess": {
"description": "Snackbar after successful conversion",
"placeholders": {
"format": {"type": "String"}
}
},
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
}
-8
View File
@@ -548,14 +548,6 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "App",
"@aboutApp": {
"description": "Section for app info"
+1276 -19
View File
File diff suppressed because it is too large Load Diff
+1107 -103
View File
File diff suppressed because it is too large Load Diff
+1028 -24
View File
File diff suppressed because it is too large Load Diff
+1037 -187
View File
File diff suppressed because it is too large Load Diff
+1028 -24
View File
File diff suppressed because it is too large Load Diff
+1041 -37
View File
File diff suppressed because it is too large Load Diff
+1028 -24
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -548,14 +548,6 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "App",
"@aboutApp": {
"description": "Section for app info"
+1316 -59
View File
File diff suppressed because it is too large Load Diff
+1028 -24
View File
File diff suppressed because it is too large Load Diff
+1028 -24
View File
File diff suppressed because it is too large Load Diff
-8
View File
@@ -548,14 +548,6 @@
"@aboutSupport": {
"description": "Section for support/donation links"
},
"aboutBuyMeCoffee": "Buy me a coffee",
"@aboutBuyMeCoffee": {
"description": "Donation link"
},
"aboutBuyMeCoffeeSubtitle": "Support development on Ko-fi",
"@aboutBuyMeCoffeeSubtitle": {
"description": "Subtitle for donation"
},
"aboutApp": "App",
"@aboutApp": {
"description": "Section for app info"
+1028 -24
View File
File diff suppressed because it is too large Load Diff
+1028 -24
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -11,12 +11,21 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
_configureImageCache();
runApp(
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
);
}
void _configureImageCache() {
final imageCache = PaintingBinding.instance.imageCache;
// Keep memory cache bounded so cover-heavy pages don't retain too many
// full-resolution images simultaneously.
imageCache.maximumSize = 240;
imageCache.maximumSizeBytes = 60 << 20; // 60 MiB
}
/// Widget to eagerly initialize providers that need to load data on startup
class _EagerInitialization extends ConsumerStatefulWidget {
const _EagerInitialization({required this.child});
+4
View File
@@ -28,6 +28,7 @@ class DownloadItem {
final DownloadStatus status;
final double progress;
final double speedMBps;
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
@@ -41,6 +42,7 @@ class DownloadItem {
this.status = DownloadStatus.queued,
this.progress = 0.0,
this.speedMBps = 0.0,
this.bytesReceived = 0,
this.filePath,
this.error,
this.errorType,
@@ -55,6 +57,7 @@ class DownloadItem {
DownloadStatus? status,
double? progress,
double? speedMBps,
int? bytesReceived,
String? filePath,
String? error,
DownloadErrorType? errorType,
@@ -68,6 +71,7 @@ class DownloadItem {
status: status ?? this.status,
progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
errorType: errorType ?? this.errorType,
+2
View File
@@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
DownloadStatus.queued,
progress: (json['progress'] 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?,
error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -30,6 +31,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived,
'filePath': instance.filePath,
'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+42 -14
View File
@@ -19,6 +19,9 @@ class AppSettings {
final String updateChannel;
final bool hasSearchedBefore;
final String folderOrganization;
final bool useAlbumArtistForFolders;
final bool usePrimaryArtistOnly; // Strip featured artists from folder name
final bool filterContributingArtistsInAlbumArtist;
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
@@ -34,18 +37,24 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
final String
tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool
useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool
autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String
downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
// Local Library Settings
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
final bool
localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
final bool
hasCompletedTutorial; // Track if user has completed the app tutorial
const AppSettings({
this.defaultService = 'tidal',
@@ -63,6 +72,9 @@ class AppSettings {
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.usePrimaryArtistOnly = false,
this.filterContributingArtistsInAlbumArtist = false,
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
@@ -106,6 +118,9 @@ class AppSettings {
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
bool? useAlbumArtistForFolders,
bool? usePrimaryArtistOnly,
bool? filterContributingArtistsInAlbumArtist,
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
@@ -149,16 +164,27 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
usePrimaryArtistOnly: usePrimaryArtistOnly ?? this.usePrimaryArtistOnly,
filterContributingArtistsInAlbumArtist:
filterContributingArtistsInAlbumArtist ??
this.filterContributingArtistsInAlbumArtist,
historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
askQualityBeforeDownload:
askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
useCustomSpotifyCredentials:
useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
metadataSource: metadataSource ?? this.metadataSource,
enableLogging: enableLogging ?? this.enableLogging,
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
useExtensionProviders:
useExtensionProviders ?? this.useExtensionProviders,
searchProvider: clearSearchProvider
? null
: (searchProvider ?? this.searchProvider),
separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
@@ -166,12 +192,14 @@ class AppSettings {
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
autoExportFailedDownloads:
autoExportFailedDownloads ?? this.autoExportFailedDownloads,
downloadNetworkMode: downloadNetworkMode ?? this.downloadNetworkMode,
// Local Library
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
localLibraryShowDuplicates:
localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
);
+8
View File
@@ -22,6 +22,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
usePrimaryArtistOnly: json['usePrimaryArtistOnly'] as bool? ?? false,
filterContributingArtistsInAlbumArtist:
json['filterContributingArtistsInAlbumArtist'] as bool? ?? false,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -68,6 +72,10 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'usePrimaryArtistOnly': instance.usePrimaryArtistOnly,
'filterContributingArtistsInAlbumArtist':
instance.filterContributingArtistsInAlbumArtist,
'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
File diff suppressed because it is too large Load Diff
+287 -42
View File
@@ -3,14 +3,18 @@ import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at';
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
final _prefs = SharedPreferences.getInstance();
class LocalLibraryState {
final List<LocalLibraryItem> items;
@@ -22,9 +26,10 @@ class LocalLibraryState {
final int scanErrorCount;
final bool scanWasCancelled;
final DateTime? lastScannedAt;
final Set<String> _isrcSet;
final int excludedDownloadedCount;
final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc;
final Map<String, LocalLibraryItem> _byTrackKey;
LocalLibraryState({
this.items = const [],
@@ -36,18 +41,23 @@ class LocalLibraryState {
this.scanErrorCount = 0,
this.scanWasCancelled = false,
this.lastScannedAt,
}) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!)
.toSet(),
_trackKeySet = items.map((item) => item.matchKey).toSet(),
_byIsrc = Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
);
this.excludedDownloadedCount = 0,
Set<String>? trackKeySet,
Map<String, LocalLibraryItem>? byIsrc,
Map<String, LocalLibraryItem>? byTrackKey,
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
_byIsrc =
byIsrc ??
Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
),
_byTrackKey =
byTrackKey ??
Map.fromEntries(items.map((item) => MapEntry(item.matchKey, item)));
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc);
bool hasTrack(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
@@ -58,7 +68,7 @@ class LocalLibraryState {
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
return items.where((item) => item.matchKey == key).firstOrNull;
return _byTrackKey[key];
}
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
@@ -81,9 +91,13 @@ class LocalLibraryState {
int? scanErrorCount,
bool? scanWasCancelled,
DateTime? lastScannedAt,
int? excludedDownloadedCount,
}) {
final nextItems = items ?? this.items;
final keepDerivedIndex = identical(nextItems, this.items);
return LocalLibraryState(
items: items ?? this.items,
items: nextItems,
isScanning: isScanning ?? this.isScanning,
scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
@@ -92,6 +106,11 @@ class LocalLibraryState {
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
excludedDownloadedCount:
excludedDownloadedCount ?? this.excludedDownloadedCount,
trackKeySet: keepDerivedIndex ? _trackKeySet : null,
byIsrc: keepDerivedIndex ? _byIsrc : null,
byTrackKey: keepDerivedIndex ? _byTrackKey : null,
);
}
}
@@ -99,11 +118,13 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer;
bool _isLoaded = false;
bool _scanCancelRequested = false;
int _progressPollingErrorCount = 0;
bool _isProgressPollingInFlight = false;
@override
LocalLibraryState build() {
@@ -122,23 +143,35 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_isLoaded = true;
try {
final jsonList = await _db.getAll();
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
final dbItemsFuture = _db.getAll();
final prefsFuture = _prefs;
final jsonList = await dbItemsFuture;
final items = jsonList
.map((e) => LocalLibraryItem.fromJson(e))
.toList(growable: false);
DateTime? lastScannedAt;
var excludedDownloadedCount = 0;
try {
final prefs = await SharedPreferences.getInstance();
final prefs = await prefsFuture;
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
}
excludedDownloadedCount =
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
} catch (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(
'Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt',
'Loaded ${items.length} items from library database, lastScannedAt: '
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
);
} catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack);
@@ -150,6 +183,58 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase();
}
Set<String> _buildPathMatchKeys(String? filePath) {
final raw = filePath?.trim() ?? '';
if (raw.isEmpty) return const {};
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7) : raw;
final keys = <String>{cleaned};
void addNormalized(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return;
keys.add(trimmed);
keys.add(trimmed.toLowerCase());
if (trimmed.contains('\\')) {
final slash = trimmed.replaceAll('\\', '/');
keys.add(slash);
keys.add(slash.toLowerCase());
}
if (trimmed.contains('%')) {
try {
final decoded = Uri.decodeFull(trimmed);
keys.add(decoded);
keys.add(decoded.toLowerCase());
} catch (_) {}
}
}
addNormalized(cleaned);
if (cleaned.startsWith('content://')) {
try {
final uri = Uri.parse(cleaned);
addNormalized(uri.toString());
addNormalized(uri.replace(query: null, fragment: null).toString());
} catch (_) {}
}
return keys;
}
bool _isDownloadedPath(String? filePath, Set<String> downloadedPathKeys) {
if (filePath == null || filePath.isEmpty || downloadedPathKeys.isEmpty) {
return false;
}
final candidateKeys = _buildPathMatchKeys(filePath);
for (final key in candidateKeys) {
if (downloadedPathKeys.contains(key)) {
return true;
}
}
return false;
}
Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
@@ -172,10 +257,16 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanErrorCount: 0,
scanWasCancelled: false,
);
await _showScanProgressNotification(
progress: 0,
scannedFiles: 0,
totalFiles: 0,
currentFile: null,
);
try {
final cacheDir = await getApplicationCacheDirectory();
final coverCacheDir = '${cacheDir.path}/library_covers';
final appSupportDir = await getApplicationSupportDirectory();
final coverCacheDir = '${appSupportDir.path}/library_covers';
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
_log.i('Cover cache directory set to: $coverCacheDir');
} catch (e) {
@@ -187,10 +278,26 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final isSaf = folderPath.startsWith('content://');
// Get all file paths from download history to exclude them
// Get all file paths from download history to exclude them.
// Merge DB + in-memory state to avoid race when a fresh download has not
// been flushed to SQLite yet.
final downloadedPaths = await _historyDb.getAllFilePaths();
final inMemoryHistoryPaths = ref
.read(downloadHistoryProvider)
.items
.map((item) => item.filePath)
.where((path) => path.isNotEmpty);
final allHistoryPaths = <String>{
...downloadedPaths,
...inMemoryHistoryPaths,
};
final downloadedPathKeys = <String>{};
for (final path in allHistoryPaths) {
downloadedPathKeys.addAll(_buildPathMatchKeys(path));
}
_log.i(
'Excluding ${downloadedPaths.length} downloaded files from library scan',
'Excluding ${allHistoryPaths.length} downloaded files from library scan '
'(${downloadedPathKeys.length} path keys)',
);
if (forceFullScan) {
@@ -200,6 +307,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
: await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return;
}
@@ -208,7 +316,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in results) {
final filePath = json['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) {
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
}
@@ -226,6 +334,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
@@ -237,9 +346,18 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100,
lastScannedAt: now,
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',
);
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
@@ -273,6 +391,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
await _showScanCancelledNotification();
return;
}
@@ -309,7 +428,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
for (final json in scannedList) {
final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?;
if (filePath != null && downloadedPaths.contains(filePath)) {
if (_isDownloadedPath(filePath, downloadedPathKeys)) {
skippedDownloads++;
continue;
}
@@ -344,6 +463,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
@@ -355,16 +475,24 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
);
_log.i(
'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)',
);
await _showScanCompleteNotification(
totalTracks: items.length,
excludedDownloadedCount: skippedDownloads,
errorCount: state.scanErrorCount,
);
}
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false, scanWasCancelled: false);
await _showScanFailedNotification(e.toString());
} finally {
_stopProgressPolling();
}
@@ -373,16 +501,43 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
void _startProgressPolling() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try {
final progress = await PlatformBridge.getLibraryScanProgress();
state = state.copyWith(
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
scanCurrentFile: progress['current_file'] as String?,
scanTotalFiles: progress['total_files'] as int? ?? 0,
scannedFiles: progress['scanned_files'] as int? ?? 0,
scanErrorCount: progress['error_count'] as int? ?? 0,
final nextProgress =
(progress['progress_pct'] as num?)?.toDouble() ?? 0;
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
0.0,
100.0,
);
final currentFile = progress['current_file'] as String?;
final totalFiles = progress['total_files'] as int? ?? 0;
final scannedFiles = progress['scanned_files'] as int? ?? 0;
final errorCount = progress['error_count'] as int? ?? 0;
final shouldUpdateState =
state.scanProgress != normalizedProgress ||
state.scanCurrentFile != currentFile ||
state.scanTotalFiles != totalFiles ||
state.scannedFiles != scannedFiles ||
state.scanErrorCount != errorCount;
if (shouldUpdateState) {
state = state.copyWith(
scanProgress: normalizedProgress,
scanCurrentFile: currentFile,
scanTotalFiles: totalFiles,
scannedFiles: scannedFiles,
scanErrorCount: errorCount,
);
await _showScanProgressNotification(
progress: normalizedProgress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: currentFile,
);
}
if (progress['is_complete'] == true) {
_stopProgressPolling();
@@ -393,6 +548,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_progressPollingErrorCount <= 3) {
_log.w('Library scan progress polling failed: $e');
}
} finally {
_isProgressPollingInFlight = false;
}
});
}
@@ -401,6 +558,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_progressTimer?.cancel();
_progressTimer = null;
_progressPollingErrorCount = 0;
_isProgressPollingInFlight = false;
}
Future<void> cancelScan() async {
@@ -411,6 +569,75 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false, scanWasCancelled: true);
_stopProgressPolling();
await _showScanCancelledNotification();
}
Future<void> _showScanProgressNotification({
required double progress,
required int scannedFiles,
required int totalFiles,
required String? currentFile,
}) async {
try {
await _notificationService.showLibraryScanProgress(
progress: progress,
scannedFiles: scannedFiles,
totalFiles: totalFiles,
currentFile: _shortenFileForNotification(currentFile),
);
} catch (e) {
_log.w('Failed to show scan progress notification: $e');
}
}
Future<void> _showScanCompleteNotification({
required int totalTracks,
required int excludedDownloadedCount,
required int errorCount,
}) async {
try {
await _notificationService.showLibraryScanComplete(
totalTracks: totalTracks,
excludedDownloadedCount: excludedDownloadedCount,
errorCount: errorCount,
);
} catch (e) {
_log.w('Failed to show scan complete notification: $e');
}
}
Future<void> _showScanFailedNotification(String message) async {
try {
await _notificationService.showLibraryScanFailed(message);
} catch (e) {
_log.w('Failed to show scan failure notification: $e');
}
}
Future<void> _showScanCancelledNotification() async {
try {
await _notificationService.showLibraryScanCancelled();
} catch (e) {
_log.w('Failed to show scan cancelled notification: $e');
}
}
String? _shortenFileForNotification(String? path) {
final raw = path?.trim() ?? '';
if (raw.isEmpty) return null;
var decoded = raw;
try {
decoded = Uri.decodeFull(raw);
} catch (_) {}
final slashIdx = decoded.lastIndexOf('/');
final backslashIdx = decoded.lastIndexOf('\\');
final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx;
if (cut >= 0 && cut < decoded.length - 1) {
return decoded.substring(cut + 1);
}
return decoded;
}
Future<int> cleanupMissingFiles() async {
@@ -427,6 +654,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey);
await prefs.remove(_excludedDownloadedCountKey);
} catch (e) {
_log.w('Failed to clear lastScannedAt: $e');
}
@@ -537,17 +765,34 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
}
final paths = legacyPaths
.where((path) => !path.startsWith('content://'))
.toList(growable: false);
const chunkSize = 24;
final backfilled = <String, int>{};
for (final path in legacyPaths) {
if (_scanCancelRequested || path.startsWith('content://')) {
continue;
for (var i = 0; i < paths.length; i += chunkSize) {
if (_scanCancelRequested) {
break;
}
try {
final stat = await File(path).stat();
if (stat.type == FileSystemEntityType.file) {
backfilled[path] = stat.modified.millisecondsSinceEpoch;
final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
final chunk = paths.sublist(i, end);
final chunkEntries = await Future.wait<MapEntry<String, int>?>(
chunk.map((path) async {
try {
final stat = await File(path).stat();
if (stat.type == FileSystemEntityType.file) {
return MapEntry(path, stat.modified.millisecondsSinceEpoch);
}
} catch (_) {}
return null;
}),
);
for (final entry in chunkEntries) {
if (entry != null) {
backfilled[entry.key] = entry.value;
}
} catch (_) {}
}
}
return backfilled;
}
+15
View File
@@ -226,6 +226,21 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
}
void setUsePrimaryArtistOnly(bool enabled) {
state = state.copyWith(usePrimaryArtistOnly: enabled);
_saveSettings();
}
void setFilterContributingArtistsInAlbumArtist(bool enabled) {
state = state.copyWith(filterContributingArtistsInAlbumArtist: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
+243 -110
View File
@@ -26,8 +26,10 @@ class TrackState {
final List<SearchPlaylist>? searchPlaylists; // For search results (playlists)
final bool hasSearchText; // For back button handling
final bool isShowingRecentAccess; // For recent access mode
final String? searchExtensionId; // Extension ID used for current search results
final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
final String?
searchExtensionId; // Extension ID used for current search results
final String?
selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist")
const TrackState({
this.tracks = const [],
@@ -52,7 +54,12 @@ class TrackState {
this.selectedSearchFilter,
});
bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty) || (searchAlbums != null && searchAlbums!.isNotEmpty) || (searchPlaylists != null && searchPlaylists!.isNotEmpty);
bool get hasContent =>
tracks.isNotEmpty ||
artistAlbums != null ||
(searchArtists != null && searchArtists!.isNotEmpty) ||
(searchAlbums != null && searchAlbums!.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists!.isNotEmpty);
TrackState copyWith({
List<Track>? tracks,
@@ -95,9 +102,12 @@ class TrackState {
searchAlbums: searchAlbums ?? this.searchAlbums,
searchPlaylists: searchPlaylists ?? this.searchPlaylists,
hasSearchText: hasSearchText ?? this.hasSearchText,
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
isShowingRecentAccess:
isShowingRecentAccess ?? this.isShowingRecentAccess,
searchExtensionId: searchExtensionId,
selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter),
selectedSearchFilter: clearSelectedSearchFilter
? null
: (selectedSearchFilter ?? this.selectedSearchFilter),
);
}
}
@@ -178,6 +188,7 @@ class SearchPlaylist {
class TrackNotifier extends Notifier<TrackState> {
int _currentRequestId = 0;
static const int _maxPreWarmTracksPerRequest = 80;
@override
TrackState build() {
@@ -197,39 +208,42 @@ class TrackNotifier extends Notifier<TrackState> {
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
// Retry logic for extension URL handlers (up to 3 attempts)
Map<String, dynamic>? result;
for (int attempt = 1; attempt <= 3; attempt++) {
result = await PlatformBridge.handleURLWithExtension(url);
if (!_isRequestValid(requestId)) return;
// Check if we got valid data
if (result != null && result['type'] == 'track' && result['track'] != null) {
if (result != null &&
result['type'] == 'track' &&
result['track'] != null) {
final trackData = result['track'] as Map<String, dynamic>;
final name = trackData['name']?.toString() ?? '';
if (name.isNotEmpty) {
break;
}
} else if (result != null && (result['type'] == 'album' || result['type'] == 'playlist')) {
} else if (result != null &&
(result['type'] == 'album' || result['type'] == 'playlist')) {
break;
} else if (result != null && result['type'] == 'artist') {
break;
}
if (attempt < 3) {
await Future.delayed(const Duration(milliseconds: 500));
}
}
if (result != null) {
final type = result['type'] as String?;
final extensionId = result['extension_id'] as String?;
if (type == 'track' && result['track'] != null) {
final trackData = result['track'] as Map<String, dynamic>;
final track = _parseSearchTrack(trackData, source: extensionId);
if (track.name.isEmpty) {
state = TrackState(
isLoading: false,
@@ -237,7 +251,7 @@ class TrackNotifier extends Notifier<TrackState> {
);
return;
}
state = TrackState(
tracks: [track],
isLoading: false,
@@ -245,15 +259,27 @@ class TrackNotifier extends Notifier<TrackState> {
searchExtensionId: extensionId,
);
return;
} else if ((type == 'album' || type == 'playlist') && result['tracks'] != null) {
} else if ((type == 'album' || type == 'playlist') &&
result['tracks'] != null) {
final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
final tracks = trackList
.map(
(t) => _parseSearchTrack(
t as Map<String, dynamic>,
source: extensionId,
),
)
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: result['album']?['id'] as String?,
albumName: result['name'] as String? ?? result['album']?['name'] as String?,
playlistName: type == 'playlist' ? result['name'] as String? : null,
albumName:
result['name'] as String? ??
result['album']?['name'] as String?,
playlistName: type == 'playlist'
? result['name'] as String?
: null,
coverUrl: result['cover_url'] as String?,
searchExtensionId: extensionId,
);
@@ -261,17 +287,29 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'artist' && result['artist'] != null) {
final artistData = result['artist'] as Map<String, dynamic>;
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final topTracksList =
artistData['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList
.map(
(t) => _parseSearchTrack(
t as Map<String, dynamic>,
source: extensionId,
),
)
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?,
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
coverUrl:
artistData['image_url'] as String? ??
artistData['images'] as String?,
headerImageUrl: artistData['header_image'] as String?,
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
@@ -282,19 +320,19 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
}
// Step 2: Try Deezer URL parsing
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
_log.i('Detected Deezer URL, parsing...');
final parsed = await PlatformBridge.parseDeezerUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
@@ -306,7 +344,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, 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();
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -316,9 +356,12 @@ class TrackNotifier extends Notifier<TrackState> {
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final playlistInfo =
metadata['playlist_info'] as Map<String, 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();
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -329,7 +372,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
@@ -341,33 +386,38 @@ class TrackNotifier extends Notifier<TrackState> {
}
return;
}
// Step 3: Try Tidal URL parsing
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
_log.i('Tidal URL parsed: type=$type, id=$id');
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
if (type == 'track') {
try {
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url);
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(
url,
);
if (!_isRequestValid(requestId)) return;
final spotifyUrl = conversion['spotify_url'] as String?;
final deezerUrl = conversion['deezer_url'] as String?;
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
final metadata =
await PlatformBridge.getSpotifyMetadataWithFallback(
spotifyUrl,
);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
@@ -378,10 +428,15 @@ class TrackNotifier extends Notifier<TrackState> {
return;
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
final deezerParsed = await PlatformBridge.parseDeezerUrl(
deezerUrl,
);
final metadata = await PlatformBridge.getDeezerMetadata(
'track',
deezerParsed['id'] as String,
);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
@@ -395,30 +450,31 @@ class TrackNotifier extends Notifier<TrackState> {
_log.w('Failed to convert Tidal URL via SongLink: $e');
}
}
// For album/artist/playlist, not yet supported
state = TrackState(
isLoading: false,
error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
error:
'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
hasSearchText: state.hasSearchText,
);
return;
}
// Step 4: Fall back to Spotify parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
Map<String, dynamic> metadata;
try {
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
} catch (e) {
rethrow;
}
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
@@ -432,7 +488,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, 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();
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -444,7 +502,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, 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 owner = playlistInfo['owner'] as Map<String, dynamic>?;
state = TrackState(
tracks: tracks,
@@ -456,7 +516,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
@@ -468,17 +530,29 @@ class TrackNotifier extends Notifier<TrackState> {
}
} catch (e) {
if (!_isRequestValid(requestId)) return;
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
state = TrackState(
isLoading: false,
error: e.toString(),
hasSearchText: state.hasSearchText,
);
}
}
Future<void> search(String query, {String? metadataSource, String? filterOverride}) async {
Future<void> search(
String query, {
String? metadataSource,
String? filterOverride,
}) async {
final requestId = ++_currentRequestId;
// Preserve selected filter during loading
final currentFilter = filterOverride ?? state.selectedSearchFilter;
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
selectedSearchFilter: currentFilter,
);
try {
final settings = ref.read(settingsProvider);
@@ -494,20 +568,23 @@ class TrackNotifier extends Notifier<TrackState> {
searchProvider.isNotEmpty;
final source = metadataSource ?? 'deezer';
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
);
Map<String, dynamic> results;
List<Track> extensionTracks = [];
if (useExtensions) {
try {
_log.d('Calling extension search API...');
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
final extResults = await PlatformBridge.searchTracksWithExtensions(
query,
limit: 20,
);
_log.i('Extensions returned ${extResults.length} tracks');
for (final t in extResults) {
try {
extensionTracks.add(_parseSearchTrack(t));
@@ -519,37 +596,52 @@ class TrackNotifier extends Notifier<TrackState> {
_log.w('Extension search failed, falling back to built-in: $e');
}
}
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 2, filter: currentFilter);
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
_log.i(
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
);
} else {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 2);
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
results = await PlatformBridge.searchSpotifyAll(
query,
trackLimit: 20,
artistLimit: 2,
);
_log.i(
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
);
}
if (!_isRequestValid(requestId)) {
_log.w('Search request cancelled (requestId=$requestId)');
return;
}
final trackList = results['tracks'] as List<dynamic>? ?? [];
final artistList = results['artists'] as List<dynamic>? ?? [];
final albumList = results['albums'] as List<dynamic>? ?? [];
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums');
_log.d(
'Raw results: ${trackList.length} tracks, ${artistList.length} artists, ${albumList.length} albums',
);
final tracks = <Track>[];
tracks.addAll(extensionTracks);
final existingIsrcs = extensionTracks
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
.map((t) => t.isrc!)
.toSet();
for (int i = 0; i < trackList.length; i++) {
final t = trackList[i];
try {
@@ -566,7 +658,7 @@ class TrackNotifier extends Notifier<TrackState> {
_log.e('Failed to parse track[$i]: $e', e);
}
}
final artists = <SearchArtist>[];
for (int i = 0; i < artistList.length; i++) {
final a = artistList[i];
@@ -580,7 +672,7 @@ class TrackNotifier extends Notifier<TrackState> {
_log.e('Failed to parse artist[$i]: $e', e);
}
}
final albums = <SearchAlbum>[];
for (int i = 0; i < albumList.length; i++) {
final a = albumList[i];
@@ -594,7 +686,7 @@ class TrackNotifier extends Notifier<TrackState> {
_log.e('Failed to parse album[$i]: $e', e);
}
}
final playlistList = results['playlists'] as List<dynamic>? ?? [];
final playlists = <SearchPlaylist>[];
for (int i = 0; i < playlistList.length; i++) {
@@ -609,9 +701,11 @@ class TrackNotifier extends Notifier<TrackState> {
_log.e('Failed to parse playlist[$i]: $e', e);
}
}
_log.i('Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully');
_log.i(
'Search complete: ${tracks.length} tracks (${extensionTracks.length} from extensions), ${artists.length} artists, ${albums.length} albums, ${playlists.length} playlists parsed successfully',
);
state = TrackState(
tracks: tracks,
searchArtists: artists,
@@ -624,31 +718,45 @@ class TrackNotifier extends Notifier<TrackState> {
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
_log.e('Search failed: $e', e, stackTrace);
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText, selectedSearchFilter: currentFilter);
state = TrackState(
isLoading: false,
error: e.toString(),
hasSearchText: state.hasSearchText,
selectedSearchFilter: currentFilter,
);
}
}
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
Future<void> customSearch(
String extensionId,
String query, {
Map<String, dynamic>? options,
}) async {
final requestId = ++_currentRequestId;
state = TrackState(
isLoading: true,
hasSearchText: state.hasSearchText,
selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading
selectedSearchFilter:
state.selectedSearchFilter, // Preserve filter during loading
);
try {
_log.i('Custom search started: extension=$extensionId, query="$query"');
final results = await PlatformBridge.customSearchWithExtension(extensionId, query, options: options);
final results = await PlatformBridge.customSearchWithExtension(
extensionId,
query,
options: options,
);
if (!_isRequestValid(requestId)) {
_log.w('Custom search request cancelled (requestId=$requestId)');
return;
}
_log.i('Custom search returned ${results.length} tracks');
final tracks = <Track>[];
for (int i = 0; i < results.length; i++) {
final t = results[i];
@@ -658,21 +766,28 @@ class TrackNotifier extends Notifier<TrackState> {
_log.e('Failed to parse custom search track[$i]: $e', e);
}
}
_log.i('Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)');
_log.i(
'Custom search complete: ${tracks.length} tracks parsed (source=$extensionId)',
);
state = TrackState(
tracks: tracks,
searchArtists: [],
isLoading: false,
hasSearchText: state.hasSearchText,
searchExtensionId: extensionId, // Store which extension was used
selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter
selectedSearchFilter:
state.selectedSearchFilter, // Preserve selected filter
);
} catch (e, stackTrace) {
if (!_isRequestValid(requestId)) return;
_log.e('Custom search failed: $e', e, stackTrace);
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
state = TrackState(
isLoading: false,
error: e.toString(),
hasSearchText: state.hasSearchText,
);
}
}
@@ -683,7 +798,10 @@ class TrackNotifier extends Notifier<TrackState> {
if (track.isrc == null || track.isrc!.isEmpty) return;
try {
final availability = await PlatformBridge.checkAvailability(track.id, track.isrc!);
final availability = await PlatformBridge.checkAvailability(
track.id,
track.isrc!,
);
final updatedTrack = Track(
id: track.id,
name: track.name,
@@ -736,11 +854,14 @@ class TrackNotifier extends Notifier<TrackState> {
}
state = state.copyWith(hasSearchText: hasText);
}
void setShowingRecentAccess(bool showing) {
if (state.isShowingRecentAccess == showing) {
return;
}
state = state.copyWith(isShowingRecentAccess: showing);
}
/// Set tracks from a collection (album/playlist) opened from search results
void setTracksFromCollection({
required List<Track> tracks,
@@ -782,9 +903,9 @@ class TrackNotifier extends Notifier<TrackState> {
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
final itemType = data['item_type']?.toString();
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
@@ -797,7 +918,10 @@ class TrackNotifier extends Notifier<TrackState> {
trackNumber: data['track_number'] as int?,
discNumber: data['disc_number'] as int?,
releaseDate: data['release_date']?.toString(),
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
source:
source ??
data['source']?.toString() ??
data['provider_id']?.toString(),
albumType: data['album_type']?.toString(),
itemType: itemType,
);
@@ -849,16 +973,25 @@ class TrackNotifier extends Notifier<TrackState> {
}
void _preWarmCacheForTracks(List<Track> tracks) {
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;
final cacheRequests = tracksWithIsrc.map((t) => {
'isrc': t.isrc!,
'track_name': t.name,
'artist_name': t.artistName,
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
'service': 'tidal',
}).toList();
if (tracks.isEmpty) return;
final cacheRequests = <Map<String, String>>[];
for (final track in tracks) {
final isrc = track.isrc;
if (isrc == null || isrc.isEmpty) {
continue;
}
cacheRequests.add({
'isrc': isrc,
'track_name': track.name,
'artist_name': track.artistName,
'spotify_id': track.id, // Include Spotify ID for Amazon lookup
'service': 'tidal',
});
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
break;
}
}
if (cacheRequests.isEmpty) return;
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
}
+405 -140
View File
@@ -1,4 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/widgets/download_service_picker.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 {
static final Map<String, _CacheEntry> _cache = {};
@@ -76,29 +78,32 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Use extensionId if available, otherwise detect from albumId prefix
final providerId = widget.extensionId ??
final providerId =
widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
ref
.read(recentAccessProvider.notifier)
.recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks;
} else {
_tracks = _AlbumCache.get(widget.albumId);
}
_artistId = widget.artistId;
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
}
@@ -133,27 +138,32 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return date;
}
Future<void> _fetchTracks() async {
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
metadata = await PlatformBridge.getDeezerMetadata(
'album',
deezerAlbumId,
);
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
}
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 artistId = albumInfo?['artist_id'] as String?;
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
@@ -199,15 +209,19 @@ Future<void> _fetchTracks() async {
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoading)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
)),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme, tracks),
@@ -219,11 +233,17 @@ Future<void> _fetchTracks() async {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final mediaSize = MediaQuery.of(context).size;
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(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@@ -244,9 +264,18 @@ Future<void> _fetchTracks() async {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
.round()
.clamp(720, 1440)
.toInt();
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
@@ -257,26 +286,37 @@ Future<void> _fetchTracks() async {
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
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(
left: 0, right: 0, bottom: 0, height: 80,
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
),
),
),
@@ -286,7 +326,7 @@ Future<void> _fetchTracks() async {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@@ -303,15 +343,19 @@ Future<void> _fetchTracks() async {
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -320,7 +364,10 @@ Future<void> _fetchTracks() async {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
@@ -328,7 +375,7 @@ Future<void> _fetchTracks() async {
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
@@ -338,18 +385,20 @@ Future<void> _fetchTracks() async {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
@@ -357,7 +406,10 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
children: [
Text(
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) ...[
const SizedBox(height: 4),
@@ -378,27 +430,61 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSecondaryContainer,
),
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)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
Icon(
Icons.calendar_today,
size: 14,
color: colorScheme.onTertiaryContainer,
),
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 +498,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
@@ -432,28 +520,35 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
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(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: tracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
}, childCount: tracks.length),
);
}
@@ -466,13 +561,23 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
}
@@ -486,27 +591,44 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
trackName: '${tracks.length} tracks',
artistName: widget.albumName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref
.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) {
final artistId = _artistId ??
final artistId =
_artistId ??
(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(
SnackBar(content: Text('Artist information not available')),
);
return;
}
if (widget.extensionId != null) {
Navigator.push(
context,
@@ -521,7 +643,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
@@ -535,10 +657,11 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
final isRateLimit =
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
@@ -577,7 +700,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
),
);
}
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -588,7 +711,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
],
),
),
@@ -605,33 +730,44 @@ class _AlbumTrackItem extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
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) {
return state.isDownloaded(track.id);
}));
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}),
);
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
),
);
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -639,8 +775,10 @@ class _AlbumTrackItem extends ConsumerWidget {
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: SizedBox(
width: 32,
child: Center(
@@ -653,14 +791,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(
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) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
@@ -668,51 +823,102 @@ child: ListTile(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
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),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
trailing: _buildDownloadButton(
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 (isInLocalLibrary) {
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;
}
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
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;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
Widget _buildDownloadButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
@@ -723,11 +929,29 @@ child: ListTile(
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(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)),
onTap: () => _handleTap(
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) {
return SizedBox(
@@ -736,7 +960,11 @@ child: ListTile(
child: Stack(
alignment: Alignment.center,
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),
],
),
@@ -748,17 +976,54 @@ child: ListTile(
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(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)),
CircularProgressIndicator(
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) {
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 {
return GestureDetector(
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
+477 -147
View File
@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -8,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
/// Screen to display downloaded tracks from a specific album
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
@@ -23,7 +25,8 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
});
@override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
ConsumerState<DownloadedAlbumScreen> createState() =>
_DownloadedAlbumScreenState();
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
@@ -31,6 +34,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final Set<String> _selectedIds = {};
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
bool _embeddedCoverRefreshScheduled = false;
List<DownloadHistoryItem>? _albumTracksSourceCache;
List<DownloadHistoryItem>? _albumTracksCache;
List<DownloadHistoryItem>? _discGroupingSourceCache;
Map<int, List<DownloadHistoryItem>>? _discGroupingCache;
List<int>? _sortedDiscNumbersCache;
List<DownloadHistoryItem>? _commonQualitySourceCache;
String? _commonQualityCache;
List<DownloadHistoryItem>? _embeddedCoverSourceCache;
String? _embeddedCoverPathCache;
bool _embeddedCoverPathResolved = false;
String get _albumLookupKey =>
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
@override
void initState() {
@@ -45,6 +62,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
super.dispose();
}
@override
void didUpdateWidget(covariant DownloadedAlbumScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.albumName != widget.albumName ||
oldWidget.artistName != widget.artistName) {
_albumTracksSourceCache = null;
_albumTracksCache = null;
_invalidateDerivedTrackCaches();
}
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
@@ -53,40 +81,77 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
// Use lowercase for case-insensitive matching
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
return itemKey == albumKey;
}).toList()
..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
) {
final cached = _albumTracksCache;
if (cached != null && identical(allItems, _albumTracksSourceCache)) {
return cached;
}
final tracks =
allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
// Use lowercase for case-insensitive matching
final itemKey =
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
return itemKey == _albumLookupKey;
}).toList()..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
_albumTracksSourceCache = allItems;
_albumTracksCache = tracks;
_invalidateDerivedTrackCaches();
return tracks;
}
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
void _invalidateDerivedTrackCaches() {
_discGroupingSourceCache = null;
_discGroupingCache = null;
_sortedDiscNumbersCache = null;
_commonQualitySourceCache = null;
_commonQualityCache = null;
_embeddedCoverSourceCache = null;
_embeddedCoverPathCache = null;
_embeddedCoverPathResolved = false;
}
Map<int, List<DownloadHistoryItem>> _getDiscGroups(
List<DownloadHistoryItem> tracks,
) {
final cached = _discGroupingCache;
if (cached != null && identical(tracks, _discGroupingSourceCache)) {
return cached;
}
final discMap = <int, List<DownloadHistoryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
discMap.putIfAbsent(discNumber, () => []).add(track);
}
_discGroupingSourceCache = tracks;
_discGroupingCache = discMap;
_sortedDiscNumbersCache = discMap.keys.toList()..sort();
return discMap;
}
List<int> _getSortedDiscNumbers(List<DownloadHistoryItem> tracks) {
_getDiscGroups(tracks);
return _sortedDiscNumbersCache ?? const [];
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
@@ -147,10 +212,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final idsToDelete = _selectedIds.toList();
final tracksById = {for (final track in currentTracks) track.id: track};
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
final item = tracksById[id];
if (item != null) {
try {
await deleteFile(item.filePath);
@@ -159,12 +225,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
}
}
@@ -176,20 +244,51 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
void _onEmbeddedCoverChanged() {
if (!mounted || _embeddedCoverRefreshScheduled) return;
_embeddedCoverRefreshScheduled = true;
_embeddedCoverPathResolved = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
_embeddedCoverRefreshScheduled = false;
if (mounted) {
setState(() {});
}
});
}
Future<void> _navigateToMetadataScreen(DownloadHistoryItem item) async {
final navigator = Navigator.of(context);
_precacheCover(item.coverUrl);
Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
));
final beforeModTime =
await DownloadedEmbeddedCoverResolver.readFileModTimeMillis(
item.filePath,
);
if (!mounted) return;
final result = await navigator.push(
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath(
item.filePath,
beforeModTime: beforeModTime,
force: result == true,
onChanged: _onEmbeddedCoverChanged,
);
}
void _precacheCover(String? url) {
@@ -197,8 +296,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return;
}
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final targetSize = (360 * dpr).round().clamp(512, 1024).toInt();
precacheImage(
CachedNetworkImageProvider(url, cacheManager: CoverCacheManager.instance),
ResizeImage(
CachedNetworkImageProvider(
url,
cacheManager: CoverCacheManager.instance,
),
width: targetSize,
height: targetSize,
),
context,
);
}
@@ -207,22 +317,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
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);
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: Center(
child: Text('No tracks found for this album'),
),
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text('No tracks found for this album')),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -244,21 +352,28 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildAppBar(context, colorScheme, tracks),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
@@ -266,15 +381,48 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
String? _resolveAlbumEmbeddedCoverPath(List<DownloadHistoryItem> tracks) {
if (_embeddedCoverPathResolved &&
identical(tracks, _embeddedCoverSourceCache)) {
return _embeddedCoverPathCache;
}
_embeddedCoverSourceCache = tracks;
_embeddedCoverPathResolved = true;
if (tracks.isEmpty) {
_embeddedCoverPathCache = null;
return null;
}
_embeddedCoverPathCache = DownloadedEmbeddedCoverResolver.resolve(
tracks.first.filePath,
onChanged: _onEmbeddedCoverChanged,
);
return _embeddedCoverPathCache;
}
Widget _buildAppBar(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final mediaSize = MediaQuery.of(context).size;
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);
final embeddedCoverPath = _resolveAlbumEmbeddedCoverPath(tracks);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -292,39 +440,67 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
.round()
.clamp(720, 1440)
.toInt();
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Blurred cover background
if (widget.coverUrl != null)
if (embeddedCoverPath != null)
Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
cacheWidth: backgroundMemCacheWidth,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
else if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
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(
left: 0, right: 0, bottom: 0, height: 80,
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
),
),
),
@@ -335,7 +511,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@@ -351,16 +527,35 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
child: embeddedCoverPath != null
? Image.file(
File(embeddedCoverPath),
fit: BoxFit.cover,
cacheWidth: (coverSize * 2).toInt(),
cacheHeight: (coverSize * 2).toInt(),
errorBuilder: (_, _, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
)
: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -369,14 +564,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
leading: IconButton(
icon: Container(
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),
),
onPressed: () => Navigator.pop(context),
@@ -384,14 +585,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final commonQuality = _getCommonQuality(tracks);
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
@@ -399,43 +608,70 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Text(
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),
Text(
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),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
Icon(
Icons.download_done,
size: 14,
color: colorScheme.onPrimaryContainer,
),
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),
if (_getCommonQuality(tracks) != null)
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer
color: commonQuality.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
commonQuality,
style: TextStyle(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.onTertiaryContainer
color: commonQuality.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
@@ -453,16 +689,38 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
if (tracks.isEmpty) return null;
final firstQuality = tracks.first.quality;
if (firstQuality == null) return null;
for (final track in tracks) {
if (track.quality != firstQuality) return null;
if (identical(tracks, _commonQualitySourceCache)) {
return _commonQualityCache;
}
if (tracks.isEmpty) {
_commonQualitySourceCache = tracks;
_commonQualityCache = null;
return null;
}
final firstQuality = tracks.first.quality;
if (firstQuality == null) {
_commonQualitySourceCache = tracks;
_commonQualityCache = null;
return null;
}
for (final track in tracks) {
if (track.quality != firstQuality) {
_commonQualitySourceCache = tracks;
_commonQualityCache = null;
return null;
}
}
_commonQualitySourceCache = tracks;
_commonQualityCache = firstQuality;
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -470,14 +728,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
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(),
if (!_isSelectionMode)
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),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
@@ -485,25 +753,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
final discMap = _groupTracksByDisc(tracks);
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final discMap = _getDiscGroups(tracks);
if (discMap.length <= 1) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
}, childCount: tracks.length),
);
}
final discNumbers = discMap.keys.toList()..sort();
final discNumbers = _getSortedDiscNumbers(tracks);
final List<Widget> children = [];
for (final discNumber in discNumbers) {
@@ -524,12 +793,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
return SliverList(delegate: SliverChildListDelegate(children));
}
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
Widget _buildDiscSeparator(
BuildContext context,
ColorScheme colorScheme,
int discNumber,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
@@ -543,7 +814,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
@@ -567,21 +842,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);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
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),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -590,12 +875,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
color: isSelected
? colorScheme.primary
: Colors.transparent,
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
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
@@ -617,7 +913,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
track.trackName,
maxLines: 1,
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(
track.artistName,
@@ -625,22 +923,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
trailing: _isSelectionMode
? null
: IconButton(
onPressed: () => _openFile(track.filePath),
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 allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
@@ -684,12 +991,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
context.l10n.downloadedAlbumSelectedCount(
selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
allSelected
? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
@@ -702,9 +1015,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
),
],
),
@@ -712,18 +1034,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
backgroundColor: selectedCount > 0
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
+1838 -1185
View File
File diff suppressed because it is too large Load Diff
+311 -110
View File
@@ -36,6 +36,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@override
void initState() {
@@ -87,9 +88,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
_commonQualityCache = _computeCommonQuality(_sortedTracksCache);
}
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) {
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
List<LocalLibraryItem> tracks,
) {
final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
@@ -158,26 +162,29 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList();
final tracksById = {for (final track in currentTracks) track.id: track};
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
final item = tracksById[id];
if (item != null) {
try {
await deleteFile(item.filePath);
} catch (_) {}
libraryNotifier.removeItem(id);
await libraryNotifier.removeItem(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
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
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
@@ -192,7 +199,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
@@ -203,19 +212,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache;
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: const Center(
child: Text('No tracks found for this album'),
),
appBar: AppBar(title: Text(widget.albumName)),
body: const Center(child: Text('No tracks found for this album')),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -241,17 +246,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
@@ -260,11 +272,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final mediaSize = MediaQuery.of(context).size;
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(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@@ -285,9 +303,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
@@ -298,24 +318,33 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
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(
left: 0, right: 0, bottom: 0, height: 80,
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
),
),
),
@@ -326,7 +355,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@@ -349,13 +378,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
color:
colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -364,14 +402,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
leading: IconButton(
icon: Container(
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),
),
onPressed: () => Navigator.pop(context),
@@ -379,14 +423,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
final commonQuality = _commonQualityCache;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
@@ -394,59 +446,101 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
Text(
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),
Text(
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),
Row(
children: [
// "Local" badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer),
Icon(
Icons.folder,
size: 14,
color: colorScheme.onTertiaryContainer,
),
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),
// Track count
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant),
Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSurfaceVariant,
),
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,
),
),
],
),
),
const SizedBox(width: 8),
// Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null)
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer
color: commonQuality.contains('24')
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
commonQuality,
style: TextStyle(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.onPrimaryContainer
color: commonQuality.contains('24')
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
@@ -463,21 +557,41 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
}
String? _getCommonQuality(List<LocalLibraryItem> tracks) {
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
// For lossy formats, use bitrate
if (first.bitrate != null && first.bitrate! > 0) {
final fmt = first.format?.toUpperCase() ?? '';
final firstBitrate = first.bitrate;
for (final track in tracks) {
if (track.bitrate != firstBitrate) {
return null;
}
}
return '$fmt ${firstBitrate}kbps'.trim();
}
// For lossless formats, use bit depth / sample rate
if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) return null;
final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
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 firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -485,14 +599,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
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(),
if (!_isSelectionMode)
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),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
@@ -500,15 +624,19 @@ 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 hasMultipleDiscs = _hasMultipleDiscsCache;
final slivers = <Widget>[];
for (final discNumber in _sortedDiscNumbersCache) {
final discTracks = discGroups[discNumber]!;
if (hasMultipleDiscs) {
slivers.add(
SliverToBoxAdapter(
@@ -517,7 +645,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
@@ -525,14 +656,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
style: Theme.of(context).textTheme.labelLarge
?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
@@ -550,35 +686,46 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
);
}
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]),
(context, index) =>
_buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length,
),
),
);
}
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);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
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),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _openFile(track.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -587,12 +734,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
color: isSelected
? colorScheme.primary
: Colors.transparent,
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
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
@@ -614,7 +772,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
track.trackName,
maxLines: 1,
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(
children: [
@@ -627,27 +787,45 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
if (track.format != null) ...[
Text('', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)),
Text(
'',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
trailing: _isSelectionMode
? null
: IconButton(
onPressed: () => _openFile(track.filePath),
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 allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
@@ -694,12 +872,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
context.l10n.downloadedAlbumSelectedCount(
selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
allSelected
? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
@@ -712,9 +896,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
),
],
),
@@ -722,18 +915,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
backgroundColor: selectedCount > 0
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
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(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const ClampingScrollPhysics(),
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
? const _NoSwipeRightPhysics()
: const ClampingScrollPhysics(),
children: tabs,
),
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 {
final Widget child;
const BouncingIcon({super.key, required this.child});
+376 -112
View File
@@ -1,4 +1,5 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
@@ -56,26 +57,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Future<void> _fetchTracksIfNeeded() async {
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
}
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
final result = await PlatformBridge.getDeezerMetadata(
'playlist',
playlistId,
);
if (!mounted) return;
// Go backend returns 'track_list' not 'tracks'
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(() {
_fetchedTracks = tracks;
_isLoading = false;
@@ -97,7 +103,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
@@ -139,14 +145,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final mediaSize = MediaQuery.of(context).size;
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(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -164,9 +177,18 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
final dpr = MediaQuery.devicePixelRatioOf(
context,
).clamp(1.0, 3.0).toDouble();
final backgroundMemCacheWidth = (constraints.maxWidth * dpr)
.round()
.clamp(720, 1440)
.toInt();
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
@@ -177,26 +199,37 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: backgroundMemCacheWidth,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
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(
left: 0, right: 0, bottom: 0, height: 80,
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
),
),
),
@@ -207,7 +240,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@@ -224,15 +257,19 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant),
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -241,17 +278,20 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
@@ -266,34 +306,63 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
Icon(
Icons.playlist_play,
size: 14,
color: colorScheme.onTertiaryContainer,
),
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(
onPressed: _tracks.isEmpty ? null : () => _downloadAll(context),
onPressed: _tracks.isEmpty
? null
: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(_tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
@@ -312,7 +381,13 @@ const SizedBox(height: 16),
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
@@ -328,7 +403,7 @@ const SizedBox(height: 16),
),
);
}
if (_error != null) {
return SliverToBoxAdapter(
child: Padding(
@@ -341,7 +416,12 @@ const SizedBox(height: 16),
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))),
Expanded(
child: Text(
_error!,
style: TextStyle(color: colorScheme.error),
),
),
],
),
),
@@ -349,7 +429,7 @@ const SizedBox(height: 16),
),
);
}
if (_tracks.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
@@ -363,21 +443,18 @@ const SizedBox(height: 16),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = _tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: _tracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = _tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
}, childCount: _tracks.length),
);
}
@@ -390,13 +467,23 @@ const SizedBox(height: 16),
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
}
@@ -409,13 +496,29 @@ const SizedBox(height: 16),
trackName: '${_tracks.length} tracks',
artistName: widget.playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
),
);
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
),
);
}
}
}
@@ -430,34 +533,45 @@ class _PlaylistTrackItem extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
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) {
return state.isDownloaded(track.id);
}));
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}),
);
// Check local library for duplicate detection
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
),
);
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -466,18 +580,58 @@ class _PlaylistTrackItem extends ConsumerWidget {
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? 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)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: track.coverUrl != null
? 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(
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) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
@@ -485,51 +639,102 @@ leading: track.coverUrl != null
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
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),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
trailing: _buildDownloadButton(
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 (isInLocalLibrary) {
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;
}
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
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;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
Widget _buildDownloadButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
@@ -540,11 +745,29 @@ leading: track.coverUrl != null
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(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)),
onTap: () => _handleTap(
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) {
return SizedBox(
@@ -553,7 +776,11 @@ leading: track.coverUrl != null
child: Stack(
alignment: Alignment.center,
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),
],
),
@@ -565,17 +792,54 @@ leading: track.coverUrl != null
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(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)),
CircularProgressIndicator(
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) {
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 {
return GestureDetector(
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,
),
),
);
}
}
+1025 -443
View File
File diff suppressed because it is too large Load Diff
+24 -23
View File
@@ -26,7 +26,9 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
if (widget.query.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final settings = ref.read(settingsProvider);
ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource);
ref
.read(trackProvider.notifier)
.search(widget.query, metadataSource: settings.metadataSource);
});
}
}
@@ -41,19 +43,20 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
final query = _searchController.text.trim();
if (query.isNotEmpty) {
final settings = ref.read(settingsProvider);
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
ref
.read(trackProvider.notifier)
.search(query, metadataSource: settings.metadataSource);
}
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(
track,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}
@override
@@ -78,10 +81,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
autofocus: widget.query.isEmpty,
),
actions: [
IconButton(
icon: const Icon(Icons.search),
onPressed: _search,
),
IconButton(icon: const Icon(Icons.search), onPressed: _search),
],
),
body: Column(
@@ -92,7 +92,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
trackState.error!,
trackState.error!,
style: TextStyle(color: colorScheme.error),
),
),
@@ -115,11 +115,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: colorScheme.onSurfaceVariant,
),
Icon(Icons.search, size: 64, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 16),
Text(
'Search for tracks',
@@ -137,11 +133,13 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 144,
memCacheHeight: 144,
cacheManager: CoverCacheManager.instance,
),
)
@@ -152,15 +150,18 @@ child: CachedNetworkImage(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
track.artistName,
maxLines: 1,
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
+316 -273
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/constants/app_info.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';
class AboutPage extends StatelessWidget {
@@ -12,7 +13,7 @@ class AboutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
@@ -20,211 +21,229 @@ class AboutPage extends StatelessWidget {
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),
),
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.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
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),
),
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.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(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: _AppHeaderCard(),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutTranslators,
),
),
),
const SliverToBoxAdapter(child: _TranslatorsSection()),
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(
child: SettingsSectionHeader(
title: context.l10n.aboutSpecialThanks,
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutTranslators),
),
const SliverToBoxAdapter(
child: _TranslatorsSection(),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'binimum',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_ContributorItem(
name: 'sjdonado',
description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'sjdonado',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'binimum',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_ContributorItem(
name: 'sjdonado',
description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'sjdonado',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutSpotiSaver,
subtitle: context.l10n.aboutSpotiSaverDesc,
onTap: () => _launchUrl('https://spotisaver.net'),
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
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: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.telegram,
title: context.l10n.aboutTelegramChannel,
subtitle: context.l10n.aboutTelegramChannelSubtitle,
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(
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: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.telegram,
title: context.l10n.aboutTelegramChannel,
subtitle: context.l10n.aboutTelegramChannelSubtitle,
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(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
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)),
],
),
),
),
);
}
@@ -239,73 +258,93 @@ class _AppHeaderCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest;
return Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final logoSize = (shortestSide * 0.22).clamp(72.0, 88.0);
final contentPadding = (cardWidth * 0.06).clamp(16.0, 24.0);
final titleGap = (16 * (1 + ((textScale - 1) * 0.2))).clamp(12.0, 20.0);
return Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: EdgeInsets.all(contentPadding),
child: Column(
children: [
Container(
width: logoSize,
height: logoSize,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo.png',
width: 88,
height: 88,
fit: BoxFit.cover,
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: logoSize,
height: logoSize,
fit: BoxFit.cover,
),
),
),
),
),
),
const SizedBox(height: 16),
Text(
AppInfo.appName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
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,
SizedBox(height: titleGap),
Text(
AppInfo.appName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
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,
),
),
),
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,
),
),
],
),
);
},
);
}
}
@@ -326,7 +365,7 @@ class _ContributorItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -340,11 +379,13 @@ class _ContributorItem extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
child: CachedNetworkImage(
imageUrl: 'https://github.com/$githubUsername.png',
width: 40,
height: 40,
fit: BoxFit.cover,
memCacheWidth: 120,
memCacheHeight: 120,
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
width: 40,
@@ -373,10 +414,7 @@ child: CachedNetworkImage(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(name, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(
description,
@@ -478,9 +516,12 @@ class _TranslatorsSection extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest;
return Padding(
@@ -494,9 +535,9 @@ class _TranslatorsSection extends StatelessWidget {
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _translators.map((translator) => _TranslatorChip(
translator: translator,
)).toList(),
children: _translators
.map((translator) => _TranslatorChip(translator: translator))
.toList(),
),
),
);
@@ -528,7 +569,9 @@ class _TranslatorChip extends StatelessWidget {
radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?',
translator.name.isNotEmpty
? translator.name[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
@@ -545,10 +588,7 @@ class _TranslatorChip extends StatelessWidget {
),
),
const SizedBox(width: 6),
Text(
translator.flag,
style: const TextStyle(fontSize: 14),
),
Text(translator.flag, style: const TextStyle(fontSize: 14)),
],
),
),
@@ -580,7 +620,7 @@ class _AboutSettingsItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -595,31 +635,34 @@ class _AboutSettingsItem extends StatelessWidget {
SizedBox(
width: 40,
height: 40,
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
child: Icon(
icon,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(title, style: Theme.of(context).textTheme.bodyLarge),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
],
),
),
if (onTap != null)
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
],
),
),
+179 -169
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/providers/settings_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';
class AppearanceSettingsPage extends ConsumerWidget {
@@ -14,7 +15,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
final themeSettings = ref.watch(themeProvider);
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
@@ -22,21 +23,21 @@ class AppearanceSettingsPage extends ConsumerWidget {
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),
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),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
SliverToBoxAdapter(
child: Padding(
@@ -77,8 +78,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
@@ -113,9 +114,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [
_LanguageSelector(
currentLocale: settings.locale,
onChanged: (locale) => ref
.read(settingsProvider.notifier)
.setLocale(locale),
onChanged: (locale) =>
ref.read(settingsProvider.notifier).setLocale(locale),
),
],
),
@@ -156,151 +156,167 @@ class _ThemePreviewCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark;
return RepaintBoundary(
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
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),
),
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final previewHeight = (cardWidth * 0.56).clamp(170.0, 220.0);
final innerWidth = (cardWidth - 48).clamp(220.0, 320.0);
final innerHeight = (previewHeight * 0.70).clamp(120.0, 160.0);
final innerPadding = (innerHeight * 0.11).clamp(12.0, 18.0);
final artworkSize = (innerHeight - (innerPadding * 2)).clamp(
80.0,
120.0,
);
Center(
child: Container(
width: 260,
height: 140,
padding: const EdgeInsets.all(16),
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: 108,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
return Container(
constraints: BoxConstraints(minHeight: previewHeight),
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned(
top: -(previewHeight * 0.25),
right: -(previewHeight * 0.25),
child: Container(
width: previewHeight,
height: previewHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(
alpha: 0.5,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
),
Positioned(
bottom: -(previewHeight * 0.15),
left: -(previewHeight * 0.15),
child: Container(
width: previewHeight * 0.75,
height: previewHeight * 0.75,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(
alpha: 0.5,
),
),
),
),
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),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: artworkSize * 0.44,
),
const SizedBox(height: 24),
Row(
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
const SizedBox(height: 8),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 24),
Row(
children: [
Icon(
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,
),
),
),
),
],
),
],
),
);
},
),
);
}
@@ -498,7 +514,7 @@ class _ThemeModeChip extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
@@ -694,7 +710,7 @@ class _LanguageSelector extends StatelessWidget {
required this.onChanged,
});
static const _allLanguages = [
static const _allLanguages = [
('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language),
@@ -735,16 +751,10 @@ static const _allLanguages = [
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(
Icons.language,
color: colorScheme.onSurfaceVariant,
),
leading: Icon(Icons.language, color: colorScheme.onSurfaceVariant),
title: Text(context.l10n.appearanceLanguage),
subtitle: Text(_getLanguageName(currentLocale)),
trailing: Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: () => _showLanguagePicker(context),
);
}
@@ -765,9 +775,9 @@ static const _allLanguages = [
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.appearanceLanguage,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
const Divider(height: 1),
@@ -781,22 +791,22 @@ static const _allLanguages = [
return ListTile(
leading: Icon(
lang.$3,
color: isSelected
? colorScheme.primary
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
lang.$2,
style: TextStyle(
color: isSelected
? colorScheme.primary
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
fontWeight: isSelected
? FontWeight.w600
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
trailing: isSelected
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
@@ -0,0 +1,705 @@
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 appCacheDirFuture = getApplicationCacheDirectory();
final tempDirFuture = getTemporaryDirectory();
final appSupportDirFuture = getApplicationSupportDirectory();
final coverStatsFuture = CoverCacheManager.getStats();
final prefsFuture = SharedPreferences.getInstance();
final trackCacheEntriesFuture = _getTrackCacheSizeSafe();
final appCacheDir = await appCacheDirFuture;
final tempDir = await tempDirFuture;
final appCachePath = p.normalize(appCacheDir.path);
final tempPath = p.normalize(tempDir.path);
final tempIsSameAsAppCache = appCachePath == tempPath;
final appCacheStatsFuture = _scanDirectory(Directory(appCachePath));
final tempStatsFuture = tempIsSameAsAppCache
? Future<_DirectoryStats?>.value(null)
: _scanDirectory(Directory(tempPath));
final appSupportDir = await appSupportDirFuture;
final libraryCoverStatsFuture = _scanDirectory(
Directory('${appSupportDir.path}/library_covers'),
);
final prefs = await prefsFuture;
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;
final appCacheStats = await appCacheStatsFuture;
final tempStats = await tempStatsFuture;
final coverStats = await coverStatsFuture;
final libraryCoverStats = await libraryCoverStatsFuture;
final trackCacheEntries = await trackCacheEntriesFuture;
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<int> _getTrackCacheSizeSafe() async {
try {
return await PlatformBridge.getTrackCacheSize();
} catch (_) {
return 0;
}
}
Future<void> _clearDirectoryContents(String path) async {
final directory = Directory(path);
if (!await directory.exists()) return;
try {
final entities = <FileSystemEntity>[];
await for (final entity in directory.list(followLinks: false)) {
entities.add(entity);
}
const deleteChunkSize = 24;
for (var i = 0; i < entities.length; i += deleteChunkSize) {
final end = (i + deleteChunkSize < entities.length)
? i + deleteChunkSize
: entities.length;
final chunk = entities.sublist(i, end);
await Future.wait(
chunk.map((entity) async {
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,
});
}
+100 -94
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.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';
class DonatePage extends StatelessWidget {
@@ -9,7 +10,7 @@ class DonatePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: CustomScrollView(
@@ -54,47 +55,6 @@ class DonatePage extends StatelessWidget {
padding: const EdgeInsets.all(16),
child: Column(
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
_DonateLinksCard(colorScheme: colorScheme),
@@ -103,57 +63,83 @@ class DonatePage extends StatelessWidget {
// Recent donors section
_RecentDonorsCard(colorScheme: colorScheme),
const SizedBox(height: 12),
const SizedBox(height: 16),
// Notice
// Combined notice card
Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline_rounded,
size: 20,
color: colorScheme.onSurfaceVariant,
Row(
children: [
Icon(
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),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'About Supporters',
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 6),
Text(
'By supporting SpotiFLAC, you become part of this app\'s history. '
'Your name will remain in this version permanently as a token of appreciation. '
'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 '
'every version it was included in.',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 10),
_NoticeLine(
icon: Icons.block,
text: 'Not selling early access, premium features, or paywalls',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.build_outlined,
text: 'Funds go to dev tools & testing devices',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.favorite_border,
text: 'Your support is the only way to keep this project alive',
colorScheme: colorScheme,
),
Divider(
height: 24,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_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,9 +200,13 @@ class _RecentDonorsCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
_DonorTile(name: 'J', colorScheme: colorScheme),
_DonorTile(name: 'Julian', colorScheme: colorScheme),
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile(name: '283Fabio', colorScheme: colorScheme),
_DonorTile(
name: '283Fabio',
name: 'Elias el Autentico',
colorScheme: colorScheme,
showDivider: false,
),
@@ -267,21 +257,6 @@ class _DonateLinksCard extends StatelessWidget {
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_DonateCardItem(
title: 'Buy Me a Coffee',
subtitle: 'buymeacoffee.com/zarzet',
customIcon: const BmacIcon(size: 22, color: Colors.black87),
color: const Color(0xFFFFDD00),
url: AppInfo.bmacUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_DonateCardItem(
title: 'GitHub Sponsors',
subtitle: 'github.com/sponsors/zarzet',
@@ -417,3 +392,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,
),
),
),
],
);
}
}
+114 -71
View File
@@ -9,6 +9,8 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.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';
class DownloadSettingsPage extends ConsumerStatefulWidget {
@@ -23,6 +25,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
bool _artistFolderFiltersExpanded = false;
@override
void initState() {
@@ -93,7 +96,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
@@ -346,6 +349,67 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ref,
settings.folderOrganization,
),
),
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),
),
SettingsItem(
icon: Icons.filter_alt_outlined,
title: 'Artist Name Filters',
subtitle: _getArtistFolderFilterSubtitle(
context,
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
filterAlbumArtistContributors:
settings.filterContributingArtistsInAlbumArtist,
),
trailing: Icon(
_artistFolderFiltersExpanded
? Icons.expand_less
: Icons.expand_more,
),
onTap: () {
setState(() {
_artistFolderFiltersExpanded =
!_artistFolderFiltersExpanded;
});
},
showDivider: !_artistFolderFiltersExpanded,
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.person_outline,
title: context.l10n.downloadUsePrimaryArtistOnly,
subtitle: settings.usePrimaryArtistOnly
? context.l10n.downloadUsePrimaryArtistOnlyEnabled
: context.l10n.downloadUsePrimaryArtistOnlyDisabled,
value: settings.usePrimaryArtistOnly,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsePrimaryArtistOnly(value),
),
if (_artistFolderFiltersExpanded)
SettingsSwitchItem(
icon: Icons.group_remove_outlined,
title: 'Filter contributing artists in Album Artist',
subtitle: settings.filterContributingArtistsInAlbumArtist
? 'Album Artist metadata uses primary artist only'
: 'Keep full Album Artist metadata value',
value: settings.filterContributingArtistsInAlbumArtist,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setFilterContributingArtistsInAlbumArtist(value),
showDivider: false,
),
],
@@ -901,17 +965,17 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
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) {
final isICloudPath =
result.contains('Mobile Documents') ||
result.contains('CloudDocs') ||
result.contains('com~apple~CloudDocs');
if (isICloudPath) {
final validation = validateIosPath(result);
if (!validation.isValid) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(context.l10n.setupIcloudNotSupported),
content: Text(
validation.errorReason ??
context.l10n.setupIcloudNotSupported,
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
@@ -974,6 +1038,20 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
}
String _getArtistFolderFilterSubtitle(
BuildContext context, {
required bool usePrimaryArtistOnly,
required bool filterAlbumArtistContributors,
}) {
final statuses = <String>[
usePrimaryArtistOnly ? 'Primary only: On' : 'Primary only: Off',
filterAlbumArtistContributors
? 'Album Artist metadata: Primary only'
: 'Album Artist metadata: Full',
];
return statuses.join(' | ');
}
String _getLyricsModeLabel(BuildContext context, String mode) {
switch (mode) {
case 'external':
@@ -1369,12 +1447,10 @@ class _ServiceSelector extends ConsumerWidget {
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag,
icon: Icons.shopping_bag_outlined,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
isDisabled: true,
disabledReason: 'Coming soon',
onTap: () {},
onTap: () => onChanged('amazon'),
),
],
),
@@ -1411,15 +1487,11 @@ class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
final bool isDisabled;
final String? disabledReason;
const _ServiceChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
this.isDisabled = false,
this.disabledReason,
});
@override
@@ -1434,66 +1506,37 @@ class _ServiceChip extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
final disabledColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.02),
colorScheme.surface,
)
: colorScheme.surfaceContainerLow;
return Expanded(
child: Tooltip(
message: isDisabled && disabledReason != null ? disabledReason! : '',
child: Material(
color: isDisabled
? disabledColor
: isSelected
? colorScheme.primaryContainer
: unselectedColor,
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: isDisabled ? null : onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
color: isDisabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: isSelected
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: 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/store_provider.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 ExtensionDetailPage extends ConsumerStatefulWidget {
@@ -55,7 +56,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final hasError = extension.status == 'error';
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/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';
class ExtensionsPage extends ConsumerStatefulWidget {
@@ -51,7 +52,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
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/local_library_provider.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 LibrarySettingsPage extends ConsumerStatefulWidget {
@@ -30,7 +31,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
// -> /storage/emulated/0/Music
try {
final uri = Uri.parse(path);
final treePath = uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final treePath =
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
@@ -156,10 +158,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
await ref.read(localLibraryProvider.notifier).startScan(
libraryPath,
forceFullScan: forceFullScan,
);
await ref
.read(localLibraryProvider.notifier)
.startScan(libraryPath, forceFullScan: forceFullScan);
}
Future<void> _cancelScan() async {
@@ -216,7 +217,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final settings = ref.watch(settingsProvider);
final libraryState = ref.watch(localLibraryProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: CustomScrollView(
@@ -260,6 +261,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter(
child: _LibraryHeroCard(
itemCount: libraryState.items.length,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning,
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
@@ -331,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6),
color: colorScheme.tertiaryContainer.withValues(
alpha: 0.6,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
@@ -347,17 +351,20 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
children: [
Text(
'Scan cancelled',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
),
const SizedBox(height: 2),
Text(
'You can retry the scan when ready.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.8),
),
),
],
),
@@ -493,6 +500,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
class _LibraryHeroCard extends StatelessWidget {
final int itemCount;
final int excludedDownloadedCount;
final bool isScanning;
final double scanProgress;
final String? scanCurrentFile;
@@ -502,6 +510,7 @@ class _LibraryHeroCard extends StatelessWidget {
const _LibraryHeroCard({
required this.itemCount,
required this.excludedDownloadedCount,
required this.isScanning,
required this.scanProgress,
this.scanCurrentFile,
@@ -527,10 +536,13 @@ class _LibraryHeroCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final displayCount = isScanning
? scannedFiles
: itemCount + excludedDownloadedCount;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 220,
constraints: const BoxConstraints(minHeight: 220),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
@@ -626,12 +638,12 @@ class _LibraryHeroCard extends StatelessWidget {
),
],
),
const Spacer(),
const SizedBox(height: 16),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
isScanning ? scannedFiles.toString() : itemCount.toString(),
displayCount.toString(),
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
@@ -644,17 +656,35 @@ class _LibraryHeroCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
isScanning
? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim()
? context.l10n
.libraryTracksCount(scannedFiles)
.replaceAll(scannedFiles.toString(), '')
.trim()
: context.l10n
.libraryTracksCount(itemCount)
.replaceAll(itemCount.toString(), '')
.trim(),
.libraryTracksCount(displayCount)
.replaceAll(displayCount.toString(), '')
.trim(),
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurfaceVariant,
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) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
@@ -670,7 +700,9 @@ class _LibraryHeroCard extends StatelessWidget {
Icon(
Icons.history,
size: 14,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
const SizedBox(width: 6),
Text(
@@ -679,7 +711,9 @@ class _LibraryHeroCard extends StatelessWidget {
),
style: TextStyle(
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:share_plus/share_plus.dart' show ShareParams, SharePlus;
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/widgets/settings_group.dart';
@@ -126,7 +127,7 @@ class _LogScreenState extends State<LogScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final logs = _filteredLogs;
return PopScope(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
const MetadataProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
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/settings_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';
class OptionsSettingsPage extends ConsumerWidget {
@@ -16,7 +17,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final extensionState = ref.watch(extensionProvider);
final hasExtensions = extensionState.extensions.isNotEmpty;
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
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:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: !_hasChanges,
+21 -8
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/options_settings_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/log_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget {
@@ -18,7 +20,7 @@ class SettingsTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView(
slivers: [
@@ -73,19 +75,29 @@ class SettingsTab extends ConsumerWidget {
icon: Icons.download_outlined,
title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
onTap: () =>
_navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.library_music_outlined,
title: l10n.settingsLocalLibrary,
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(
icon: Icons.tune_outlined,
title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
onTap: () =>
_navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
@@ -138,7 +150,7 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
@@ -146,9 +158,10 @@ class SettingsTab extends ConsumerWidget {
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var tween = Tween(
begin: begin,
end: end,
).chain(CurveTween(curve: curve));
return SlideTransition(
position: animation.drive(tween),
child: child,
+143 -71
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/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key});
@@ -67,10 +68,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _checkInitialPermissions() async {
if (Platform.isIOS) {
final notificationStatus = await Permission.notification.status;
if (mounted) {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
_notificationPermissionGranted =
notificationStatus.isGranted || notificationStatus.isProvisional;
});
}
} else if (Platform.isAndroid) {
@@ -180,7 +183,14 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Future<void> _requestNotificationPermission() async {
setState(() => _isLoading = true);
try {
if (_androidSdkVersion >= 33) {
if (Platform.isIOS) {
final status = await Permission.notification.request();
if (status.isGranted || status.isProvisional) {
setState(() => _notificationPermissionGranted = true);
} else if (status.isPermanentlyDenied) {
await _showPermissionDeniedDialog('Notification');
}
} else if (_androidSdkVersion >= 33) {
final status = await Permission.notification.request();
if (status.isGranted) {
setState(() => _notificationPermissionGranted = true);
@@ -248,7 +258,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.setupUseDefaultFolder),
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'),
content: Text(
'${context.l10n.setupNoFolderSelected}\n\n$defaultDir',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -320,6 +332,22 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Navigator.pop(ctx);
final result = await FilePicker.platform.getDirectoryPath();
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);
}
},
@@ -576,37 +604,60 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Widget _buildWelcomeStep(ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo-transparant.png',
width: 104,
height: 104,
color: colorScheme.primary,
fit: BoxFit.contain,
),
const SizedBox(height: 32),
Text(
context.l10n.appName,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
return LayoutBuilder(
builder: (context, constraints) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final logoSize = (shortestSide * 0.24).clamp(80.0, 104.0);
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
final subtitleGap = (shortestSide * 0.04).clamp(8.0, 16.0);
final minContentHeight = constraints.maxHeight > 48
? constraints.maxHeight - 48
: 0.0;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minContentHeight),
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 +884,58 @@ class _StepLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
return LayoutBuilder(
builder: (context, constraints) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0);
final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0);
final minContentHeight = constraints.maxHeight > 48
? 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 +949,25 @@ class _SuccessCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 12),
Text(
text,
style: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
Expanded(
child: Text(
text,
style: TextStyle(
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/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key});
@@ -44,7 +45,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Widget build(BuildContext context) {
final state = ref.watch(storeProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: RefreshIndicator(
File diff suppressed because it is too large Load Diff
+161 -105
View File
@@ -16,6 +16,26 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
int _currentPage = 0;
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
void dispose() {
_pageController.dispose();
@@ -55,6 +75,15 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
final colorScheme = Theme.of(context).colorScheme;
final l10n = context.l10n;
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(
backgroundColor: colorScheme.surface,
@@ -63,7 +92,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
children: [
// Top Navigation Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
vertical: topBarPaddingV,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -199,9 +231,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack,
margin: const EdgeInsets.symmetric(horizontal: 4),
height: 8,
width: isActive ? 32 : 8,
margin: EdgeInsets.symmetric(horizontal: 4 * scale),
height: pageIndicatorHeight,
width: isActive
? activeIndicatorWidth
: pageIndicatorWidth,
decoration: BoxDecoration(
color: isActive
? colorScheme.primary
@@ -211,11 +245,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
);
}),
),
const SizedBox(height: 32),
SizedBox(height: bottomGap),
// Action Button
SizedBox(
width: double.infinity,
height: 56,
height: actionButtonHeight,
child: FilledButton(
onPressed: _nextPage,
style: FilledButton.styleFrom(
@@ -520,104 +554,114 @@ class _InteractiveDownloadExampleState
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Row(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: 36,
color: colorScheme.onPrimaryContainer,
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 140,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(7),
),
child: Row(
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
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
? SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.onPrimary,
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
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
Widget build(BuildContext context) {
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)
// In a real advanced implementation we'd pass the Controller's listenable
@@ -656,23 +712,23 @@ class _TutorialPage extends StatelessWidget {
physics: const BouncingScrollPhysics(),
child: Column(
children: [
const SizedBox(height: 24),
SizedBox(height: topGap),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack,
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
padding: const EdgeInsets.all(24),
padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration(
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 56,
size: iconSize,
color: iconColor ?? colorScheme.primary,
),
),
const SizedBox(height: 48),
SizedBox(height: iconTextGap),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
@@ -687,7 +743,7 @@ class _TutorialPage extends StatelessWidget {
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
SizedBox(height: descriptionGap),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
@@ -697,14 +753,14 @@ class _TutorialPage extends StatelessWidget {
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
fontSize: 16,
fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 56),
SizedBox(height: contentGap),
content, // The content itself now handles its own internal animations
const SizedBox(height: 32),
SizedBox(height: bottomGap),
],
),
);
+154
View File
@@ -0,0 +1,154 @@
class DownloadRequestPayload {
final String isrc;
final String service;
final String spotifyId;
final String trackName;
final String artistName;
final String albumName;
final String albumArtist;
final String coverUrl;
final String outputDir;
final String filenameFormat;
final String quality;
final bool embedLyrics;
final bool embedMaxQualityCover;
final int trackNumber;
final int discNumber;
final int totalTracks;
final String releaseDate;
final String itemId;
final int durationMs;
final String source;
final String genre;
final String label;
final String copyright;
final String tidalId;
final String qobuzId;
final String deezerId;
final String lyricsMode;
final bool useExtensions;
final bool useFallback;
final String storageMode;
final String safTreeUri;
final String safRelativeDir;
final String safFileName;
final String safOutputExt;
const DownloadRequestPayload({
this.isrc = '',
this.service = '',
this.spotifyId = '',
required this.trackName,
required this.artistName,
required this.albumName,
this.albumArtist = '',
this.coverUrl = '',
required this.outputDir,
required this.filenameFormat,
this.quality = 'LOSSLESS',
this.embedLyrics = true,
this.embedMaxQualityCover = true,
this.trackNumber = 1,
this.discNumber = 1,
this.totalTracks = 1,
this.releaseDate = '',
this.itemId = '',
this.durationMs = 0,
this.source = '',
this.genre = '',
this.label = '',
this.copyright = '',
this.tidalId = '',
this.qobuzId = '',
this.deezerId = '',
this.lyricsMode = 'embed',
this.useExtensions = false,
this.useFallback = false,
this.storageMode = 'app',
this.safTreeUri = '',
this.safRelativeDir = '',
this.safFileName = '',
this.safOutputExt = '',
});
Map<String, dynamic> toJson() {
return {
'isrc': isrc,
'service': service,
'spotify_id': spotifyId,
'track_name': trackName,
'artist_name': artistName,
'album_name': albumName,
'album_artist': albumArtist,
'cover_url': coverUrl,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'track_number': trackNumber,
'disc_number': discNumber,
'total_tracks': totalTracks,
'release_date': releaseDate,
'item_id': itemId,
'duration_ms': durationMs,
'source': source,
'genre': genre,
'label': label,
'copyright': copyright,
'tidal_id': tidalId,
'qobuz_id': qobuzId,
'deezer_id': deezerId,
'lyrics_mode': lyricsMode,
'use_extensions': useExtensions,
'use_fallback': useFallback,
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
};
}
DownloadRequestPayload withStrategy({
bool? useExtensions,
bool? useFallback,
}) {
return DownloadRequestPayload(
isrc: isrc,
service: service,
spotifyId: spotifyId,
trackName: trackName,
artistName: artistName,
albumName: albumName,
albumArtist: albumArtist,
coverUrl: coverUrl,
outputDir: outputDir,
filenameFormat: filenameFormat,
quality: quality,
embedLyrics: embedLyrics,
embedMaxQualityCover: embedMaxQualityCover,
trackNumber: trackNumber,
discNumber: discNumber,
totalTracks: totalTracks,
releaseDate: releaseDate,
itemId: itemId,
durationMs: durationMs,
source: source,
genre: genre,
label: label,
copyright: copyright,
tidalId: tidalId,
qobuzId: qobuzId,
deezerId: deezerId,
lyricsMode: lyricsMode,
useExtensions: useExtensions ?? this.useExtensions,
useFallback: useFallback ?? this.useFallback,
storageMode: storageMode,
safTreeUri: safTreeUri,
safRelativeDir: safRelativeDir,
safFileName: safFileName,
safOutputExt: safOutputExt,
);
}
}
@@ -0,0 +1,220 @@
import 'dart:collection';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
class _EmbeddedCoverCacheEntry {
final String previewPath;
final int? sourceModTimeMillis;
const _EmbeddedCoverCacheEntry({
required this.previewPath,
this.sourceModTimeMillis,
});
}
/// Shared resolver for embedded cover previews from downloaded/local files.
/// It keeps a bounded in-memory cache and only refreshes extraction
/// when the source file changed.
class DownloadedEmbeddedCoverResolver {
static const int _maxCacheEntries = 180;
static final LinkedHashMap<String, _EmbeddedCoverCacheEntry> _cache =
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
static final Set<String> _pendingExtract = <String>{};
static final Set<String> _pendingRefresh = <String>{};
static final Set<String> _failedExtract = <String>{};
static String cleanFilePath(String? filePath) {
if (filePath == null) return '';
if (filePath.startsWith('EXISTS:')) {
return filePath.substring(7);
}
return filePath;
}
static Future<int?> readFileModTimeMillis(String? filePath) async {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
if (isContentUri(cleanPath)) {
try {
final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]);
return modTimes[cleanPath];
} catch (_) {
return null;
}
}
try {
final stat = await File(cleanPath).stat();
return stat.modified.millisecondsSinceEpoch;
} catch (_) {
return null;
}
}
static String? resolve(String? filePath, {VoidCallback? onChanged}) {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
if (_pendingRefresh.remove(cleanPath)) {
_ensureCover(cleanPath, forceRefresh: true, onChanged: onChanged);
}
final cached = _cache[cleanPath];
if (cached != null) {
if (File(cached.previewPath).existsSync()) {
_touch(cleanPath, cached);
return cached.previewPath;
}
_cache.remove(cleanPath);
_cleanupTempCoverPathSync(cached.previewPath);
}
return null;
}
static Future<void> scheduleRefreshForPath(
String? filePath, {
int? beforeModTime,
bool force = false,
VoidCallback? onChanged,
}) async {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return;
if (!force) {
if (beforeModTime == null) return;
final afterModTime = await readFileModTimeMillis(cleanPath);
if (afterModTime != null && afterModTime == beforeModTime) {
return;
}
}
_pendingRefresh.add(cleanPath);
_failedExtract.remove(cleanPath);
onChanged?.call();
}
static void invalidate(String? filePath) {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return;
final cached = _cache.remove(cleanPath);
_pendingExtract.remove(cleanPath);
_pendingRefresh.remove(cleanPath);
_failedExtract.remove(cleanPath);
if (cached != null) {
_cleanupTempCoverPathSync(cached.previewPath);
}
}
static void invalidatePathsNotIn(Set<String> validCleanPaths) {
if (validCleanPaths.isEmpty) {
final keys = _cache.keys.toList(growable: false);
for (final key in keys) {
invalidate(key);
}
return;
}
final staleKeys = _cache.keys
.where((path) => !validCleanPaths.contains(path))
.toList(growable: false);
for (final key in staleKeys) {
invalidate(key);
}
}
static void _touch(String cleanPath, _EmbeddedCoverCacheEntry entry) {
_cache
..remove(cleanPath)
..[cleanPath] = entry;
}
static void _trimCacheIfNeeded() {
while (_cache.length > _maxCacheEntries) {
final oldestKey = _cache.keys.first;
final removed = _cache.remove(oldestKey);
if (removed != null) {
_cleanupTempCoverPathSync(removed.previewPath);
}
_pendingExtract.remove(oldestKey);
_pendingRefresh.remove(oldestKey);
_failedExtract.remove(oldestKey);
}
}
static void _ensureCover(
String cleanPath, {
bool forceRefresh = false,
int? knownModTime,
VoidCallback? onChanged,
}) {
if (cleanPath.isEmpty) return;
if (_pendingExtract.contains(cleanPath)) return;
if (!forceRefresh && _cache.containsKey(cleanPath)) return;
if (!forceRefresh && _failedExtract.contains(cleanPath)) return;
_pendingExtract.add(cleanPath);
Future.microtask(() async {
String? outputPath;
try {
final modTime = knownModTime ?? await readFileModTimeMillis(cleanPath);
final tempDir = await Directory.systemTemp.createTemp(
'download_cover_preview_',
);
outputPath =
'${tempDir.path}${Platform.pathSeparator}cover_preview.jpg';
final result = await PlatformBridge.extractCoverToFile(
cleanPath,
outputPath,
);
final hasCover =
result['error'] == null && await File(outputPath).exists();
if (!hasCover) {
_failedExtract.add(cleanPath);
_cleanupTempCoverPathSync(outputPath);
return;
}
final previous = _cache[cleanPath];
final next = _EmbeddedCoverCacheEntry(
previewPath: outputPath,
sourceModTimeMillis: modTime,
);
_touch(cleanPath, next);
_failedExtract.remove(cleanPath);
_trimCacheIfNeeded();
if (previous != null && previous.previewPath != outputPath) {
_cleanupTempCoverPathSync(previous.previewPath);
}
onChanged?.call();
} catch (_) {
_failedExtract.add(cleanPath);
_cleanupTempCoverPathSync(outputPath);
} finally {
_pendingExtract.remove(cleanPath);
}
});
}
static void _cleanupTempCoverPathSync(String? coverPath) {
if (coverPath == null || coverPath.isEmpty) return;
try {
final file = File(coverPath);
if (file.existsSync()) {
file.deleteSync();
}
final parent = file.parent;
if (parent.existsSync()) {
parent.deleteSync(recursive: true);
}
} catch (_) {}
}
}
+260 -7
View File
@@ -11,6 +11,7 @@ final _log = AppLogger('FFmpeg');
class FFmpegService {
static const int _commandLogPreviewLength = 300;
static int _tempEmbedCounter = 0;
static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
@@ -47,6 +48,56 @@ class FFmpegService {
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 List<String> _buildDecryptionKeyCandidates(String rawKey) {
final candidates = <String>[];
void addCandidate(String key) {
final normalized = key.trim();
if (normalized.isEmpty) return;
if (!candidates.contains(normalized)) {
candidates.add(normalized);
}
}
final trimmed = rawKey.trim();
if (trimmed.isEmpty) return candidates;
addCandidate(trimmed);
final noPrefix = trimmed.startsWith(RegExp(r'0x', caseSensitive: false))
? trimmed.substring(2)
: trimmed;
addCandidate(noPrefix);
final compactHex = noPrefix.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
if (compactHex.isNotEmpty && compactHex.length.isEven) {
addCandidate(compactHex);
}
try {
final b64 = noPrefix.replaceAll(RegExp(r'\s+'), '');
final decoded = base64Decode(b64);
if (decoded.isNotEmpty) {
final hex = decoded
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (hex.isNotEmpty) {
addCandidate(hex);
}
}
} catch (_) {}
return candidates;
}
static Future<FFmpegResult> _execute(String command) async {
try {
final session = await FFmpegKit.execute(command);
@@ -68,7 +119,7 @@ class FFmpegService {
final outputPath = _buildOutputPath(inputPath, '.flac');
final command =
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
'-v error -xerror -i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command);
@@ -124,6 +175,111 @@ class FFmpegService {
return null;
}
static Future<String?> decryptAudioFile({
required String inputPath,
required String decryptionKey,
bool deleteOriginal = true,
}) async {
final trimmedKey = decryptionKey.trim();
if (trimmedKey.isEmpty) return inputPath;
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
? '.flac'
: inputPath.toLowerCase().endsWith('.flac')
? '.flac'
: inputPath.toLowerCase().endsWith('.mp3')
? '.mp3'
: inputPath.toLowerCase().endsWith('.opus')
? '.opus'
: '.flac';
var tempOutput = _buildOutputPath(inputPath, preferredExt);
String buildDecryptCommand(
String outputPath, {
required bool mapAudioOnly,
required String key,
}) {
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
}
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
if (keyCandidates.isEmpty) {
_log.e('No usable decryption key candidates');
return null;
}
FFmpegResult? lastResult;
var decryptSucceeded = false;
for (final keyCandidate in keyCandidates) {
_log.d(
'Executing FFmpeg decrypt command (key length: ${keyCandidate.length})',
);
var result = await _execute(
buildDecryptCommand(
tempOutput,
mapAudioOnly: preferredExt == '.flac',
key: keyCandidate,
),
);
// Fallback for uncommon streams that cannot be remuxed into FLAC.
if (!result.success && preferredExt == '.flac') {
final fallbackOutput = _buildOutputPath(inputPath, '.m4a');
final fallbackResult = await _execute(
buildDecryptCommand(
fallbackOutput,
mapAudioOnly: false,
key: keyCandidate,
),
);
if (fallbackResult.success) {
tempOutput = fallbackOutput;
result = fallbackResult;
}
}
if (result.success) {
decryptSucceeded = true;
lastResult = result;
break;
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
lastResult = result;
}
if (!decryptSucceeded) {
_log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}');
return null;
}
try {
final tempFile = File(tempOutput);
final inputFile = File(inputPath);
if (!await tempFile.exists()) {
_log.e('Decrypted output file not found: $tempOutput');
return null;
}
if (deleteOriginal && await inputFile.exists()) {
await inputFile.delete();
}
return tempOutput;
} catch (e) {
_log.e('Failed to finalize decrypted file: $e');
return null;
}
}
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
@@ -269,8 +425,7 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
@@ -347,8 +502,7 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
@@ -358,6 +512,7 @@ class FFmpegService {
}
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
@@ -429,12 +584,13 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$opusPath" ');
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
cmdBuffer.write('-map_metadata:s:a -1 ');
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
@@ -607,6 +763,97 @@ class FFmpegService {
}
}
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
/// Returns the new file path on success, null on failure.
static Future<String?> convertAudioFormat({
required String inputPath,
required String targetFormat,
required String bitrate,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final format = targetFormat.toLowerCase();
if (format != 'mp3' && format != 'opus') {
_log.e('Unsupported target format: $targetFormat');
return null;
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
// Step 1: Convert audio
String command;
if (format == 'opus') {
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
}
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to $format @ $bitrate',
);
final result = await _execute(command);
if (!result.success) {
_log.e('Audio conversion failed: ${result.output}');
return null;
}
// Step 2: Embed metadata + cover into the converted file.
// Treat embed failure as conversion failure when metadata/cover was requested.
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
if (hasMetadata || hasCover) {
String? embedResult;
if (format == 'mp3') {
embedResult = await embedMetadataToMp3(
mp3Path: outputPath,
coverPath: coverPath,
metadata: metadata,
);
} else {
embedResult = await embedMetadataToOpus(
opusPath: outputPath,
coverPath: coverPath,
metadata: metadata,
);
}
if (embedResult == null) {
_log.e(
'Metadata/Cover preservation failed, rolling back converted file',
);
try {
final out = File(outputPath);
if (await out.exists()) {
await out.delete();
}
} catch (e) {
_log.w('Failed to cleanup failed converted file: $e');
}
return null;
}
}
// Step 3: Delete original if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
static Map<String, String> _convertToId3Tags(
Map<String, String> vorbisMetadata,
) {
@@ -648,6 +895,12 @@ class FFmpegService {
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
case 'COMPOSER':
id3Map['composer'] = value;
break;
case 'COMMENT':
id3Map['comment'] = value;
break;
default:
id3Map[key.toLowerCase()] = value;
}
+122 -75
View File
@@ -17,21 +17,21 @@ String? _currentContainerPath;
class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init();
static Database? _database;
HistoryDatabase._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('history.db');
return _database!;
}
Future<Database> _initDB(String fileName) async {
final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName);
_log.i('Initializing database at: $path');
return await openDatabase(
path,
version: 3,
@@ -39,10 +39,10 @@ class HistoryDatabase {
onUpgrade: _upgradeDB,
);
}
Future<void> _createDB(Database db, int version) async {
_log.i('Creating database schema v$version');
await db.execute('''
CREATE TABLE history (
id TEXT PRIMARY KEY,
@@ -73,16 +73,20 @@ class HistoryDatabase {
copyright TEXT
)
''');
// Indexes for fast lookups
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)');
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)');
await db.execute(
'CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)',
);
await db.execute(
'CREATE INDEX idx_album ON history(album_name, album_artist)',
);
_log.i('Database schema created with indexes');
}
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
if (oldVersion < 2) {
@@ -95,20 +99,20 @@ class HistoryDatabase {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
}
}
// ==================== iOS Path Normalization ====================
/// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
caseSensitive: false,
);
/// Initialize and cache the current iOS container path
Future<void> _initContainerPath() async {
if (!Platform.isIOS || _currentContainerPath != null) return;
try {
final docDir = await getApplicationDocumentsDirectory();
// Extract container path up to and including the UUID folder
@@ -122,55 +126,58 @@ class HistoryDatabase {
_log.w('Failed to get iOS container path: $e');
}
}
/// Normalize iOS file path by replacing old container UUID with current one
/// This fixes the issue where iOS changes container UUID after app updates
String _normalizeIosPath(String? filePath) {
if (filePath == null || filePath.isEmpty) return filePath ?? '';
if (!Platform.isIOS || _currentContainerPath == null) return filePath;
// Check if path contains an iOS container path
if (_iosContainerPattern.hasMatch(filePath)) {
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!);
final normalized = filePath.replaceFirst(
_iosContainerPattern,
_currentContainerPath!,
);
if (normalized != filePath) {
_log.d('Normalized iOS path: $filePath -> $normalized');
}
return normalized;
}
return filePath;
}
/// Migrate iOS paths in database to use current container UUID
/// This is called once after app update if container changed
Future<bool> migrateIosContainerPaths() async {
if (!Platform.isIOS) return false;
await _initContainerPath();
if (_currentContainerPath == null) return false;
final prefs = await _prefs;
final lastContainer = prefs.getString('ios_last_container_path');
if (lastContainer == _currentContainerPath) {
_log.d('iOS container path unchanged, skipping migration');
return false;
}
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
try {
final db = await database;
// Get all items with iOS paths
final rows = await db.query('history', columns: ['id', 'file_path']);
int updatedCount = 0;
final batch = db.batch();
for (final row in rows) {
final id = row['id'] as String;
final oldPath = row['file_path'] as String?;
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
final newPath = _normalizeIosPath(oldPath);
if (newPath != oldPath) {
@@ -184,14 +191,14 @@ class HistoryDatabase {
}
}
}
if (updatedCount > 0) {
await batch.commit(noResult: true);
}
// Save current container path
await prefs.setString('ios_last_container_path', _currentContainerPath!);
_log.i('iOS path migration complete: $updatedCount paths updated');
return updatedCount > 0;
} catch (e, stack) {
@@ -199,32 +206,34 @@ class HistoryDatabase {
return false;
}
}
/// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async {
final prefs = await _prefs;
final migrationKey = 'history_migrated_to_sqlite';
if (prefs.getBool(migrationKey) == true) {
_log.d('Already migrated to SQLite');
return false;
}
final jsonStr = prefs.getString('download_history');
if (jsonStr == null || jsonStr.isEmpty) {
_log.d('No SharedPreferences history to migrate');
await prefs.setBool(migrationKey, true);
return false;
}
try {
final List<dynamic> jsonList = jsonDecode(jsonStr);
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite');
_log.i(
'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
);
final db = await database;
final batch = db.batch();
for (final json in jsonList) {
final map = json as Map<String, dynamic>;
batch.insert(
@@ -233,20 +242,20 @@ class HistoryDatabase {
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
// Mark as migrated but keep old data for safety
await prefs.setBool(migrationKey, true);
_log.i('Migration complete: ${jsonList.length} items');
return true;
} catch (e, stack) {
_log.e('Migration failed: $e', e, stack);
return false;
}
}
/// Convert JSON format (camelCase) to DB row (snake_case)
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return {
@@ -278,7 +287,7 @@ class HistoryDatabase {
'copyright': json['copyright'],
};
}
/// Convert DB row (snake_case) to JSON format (camelCase)
/// Also normalizes iOS paths if container UUID changed
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
@@ -311,9 +320,9 @@ class HistoryDatabase {
'copyright': row['copyright'],
};
}
// ==================== CRUD Operations ====================
/// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async {
final db = await database;
@@ -323,7 +332,7 @@ class HistoryDatabase {
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
/// Get all history items ordered by download date (newest first)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database;
@@ -335,7 +344,7 @@ class HistoryDatabase {
);
return rows.map(_dbRowToJson).toList();
}
/// Get item by ID
Future<Map<String, dynamic>?> getById(String id) async {
final db = await database;
@@ -348,7 +357,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by Spotify ID - O(1) with index
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
final db = await database;
@@ -361,7 +370,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Get item by ISRC - O(1) with index
Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database;
@@ -374,7 +383,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
/// Check if spotify_id exists - O(1) with index
Future<bool> existsBySpotifyId(String spotifyId) async {
final db = await database;
@@ -384,42 +393,42 @@ class HistoryDatabase {
);
return result.isNotEmpty;
}
/// Get all spotify_ids as Set for fast in-memory lookup
Future<Set<String>> getAllSpotifyIds() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""'
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""',
);
return rows.map((r) => r['spotify_id'] as String).toSet();
}
/// Delete by ID
Future<void> deleteById(String id) async {
final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]);
}
/// Delete by Spotify ID
Future<void> deleteBySpotifyId(String spotifyId) async {
final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
}
/// Clear all history
Future<void> clearAll() async {
final db = await database;
await db.delete('history');
_log.i('Cleared all history');
}
/// Get total count
Future<int> getCount() async {
final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
return Sqflite.firstIntValue(result) ?? 0;
}
/// Find existing item by spotify_id or isrc (for deduplication)
Future<Map<String, dynamic>?> findExisting({
String? spotifyId,
@@ -428,7 +437,7 @@ class HistoryDatabase {
if (spotifyId != null && spotifyId.isNotEmpty) {
final bySpotify = await getBySpotifyId(spotifyId);
if (bySpotify != null) return bySpotify;
// Check for deezer: prefix matching
if (spotifyId.startsWith('deezer:')) {
final deezerId = spotifyId.substring(7);
@@ -442,31 +451,63 @@ class HistoryDatabase {
if (rows.isNotEmpty) return _dbRowToJson(rows.first);
}
}
if (isrc != null && isrc.isNotEmpty) {
return await getByIsrc(isrc);
}
return null;
}
/// Close database
/// Close database
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
/// Update file path for a history entry (e.g. after format conversion)
Future<void> updateFilePath(
String id,
String newFilePath, {
String? newSafFileName,
String? newQuality,
int? newBitDepth,
int? newSampleRate,
bool clearAudioSpecs = false,
}) async {
final db = await database;
final values = <String, dynamic>{'file_path': newFilePath};
if (newSafFileName != null) {
values['saf_file_name'] = newSafFileName;
}
if (newQuality != null) {
values['quality'] = newQuality;
}
if (clearAudioSpecs) {
values['bit_depth'] = null;
values['sample_rate'] = null;
} else {
if (newBitDepth != null) {
values['bit_depth'] = newBitDepth;
}
if (newSampleRate != null) {
values['sample_rate'] = newSampleRate;
}
}
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
}
/// Get all file paths from download history
/// Used to exclude downloaded files from local library scan
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""',
);
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Get all entries with file paths for orphan detection
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
@@ -478,18 +519,24 @@ class HistoryDatabase {
''');
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0;
final db = await database;
final placeholders = List.filled(ids.length, '?').join(',');
final count = await db.rawDelete(
'DELETE FROM history WHERE id IN ($placeholders)',
ids,
);
_log.i('Deleted $count orphaned entries');
return count;
var totalDeleted = 0;
const chunkSize = 500;
for (var i = 0; i < ids.length; i += chunkSize) {
final end = (i + chunkSize < ids.length) ? i + chunkSize : ids.length;
final chunk = ids.sublist(i, end);
final placeholders = List.filled(chunk.length, '?').join(',');
totalDeleted += await db.rawDelete(
'DELETE FROM history WHERE id IN ($placeholders)',
chunk,
);
}
_log.i('Deleted $totalDeleted orphaned entries');
return totalDeleted;
}
}

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