Compare commits

...

60 Commits

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

- Wrap popular track items in Consumer for scoped provider watches

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

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

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

- Memoize filter/sort computations per build pass

- Isolate queue header/list into dedicated Consumer slivers

- Fix Positioned/ValueListenableBuilder nesting order in grid view
2026-02-08 14:20:18 +07:00
zarzet 7229602343 feat: replace date filter with sorting (latest/oldest/A-Z/Z-A)
- Remove broken date range filter (today/week/month/year)
- Add sort options: Latest, Oldest, A-Z, Z-A
- Sorting applies to tracks (all/singles tabs) and albums tab
- Add l10n keys for sort labels
2026-02-08 13:44:02 +07:00
zarzet 1c81c53699 fix: library filters now apply to date/albums and update tab counts
- Remove redundant manual export button from queue header
- Add date range filtering support for local library items
- Apply advanced filters (date, quality, format, source) to album tab
- Tab chip counts (All/Albums/Singles) now reflect filtered results
- Extract reusable filter helpers: _passesDateFilter, _passesQualityFilter, _passesFormatFilter
- Add _filterGroupedAlbums and _filterGroupedLocalAlbums methods
2026-02-08 13:09:19 +07:00
zarzet 5256d6197b fix: metadata enrichment bug and upgrade go-flac to v2
- Fix metadata enrichment bug where failed downloads poison connection pool
  - Create separate metadataTransport for Deezer API calls
  - Add immediate connection cleanup after download failures
- Fix Samsung One UI local library scan with MediaStore fallback
- Fix 'In Library' tracks still showing as downloadable
- Upgrade go-flac packages to v2 (flacpicture v2.0.2, flacvorbis v2.0.2, go-flac v2.0.4)
- Update CHANGELOG.md v3.5.2
2026-02-08 12:01:08 +07:00
Zarz Eleutherius 79a6c8cdc0 Merge pull request #139 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-08 08:31:29 +07:00
renovate[bot] aa3b4d7d1e fix(deps): update go dependencies to v2 2026-02-07 21:39:25 +00:00
zarzet cd220a4650 merge: sync main into dev (README updates) 2026-02-08 02:51:05 +07:00
Zarz Eleutherius d71b2a9ab8 Update README to remove Search Source and enhance Telegram links 2026-02-08 02:48:29 +07:00
zarzet a2efe7243d docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:36:15 +07:00
zarzet e0acda14e4 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:33:56 +07:00
Zarz Eleutherius 029ab8ea47 Update VirusTotal badge link in README 2026-02-08 02:30:22 +07:00
zarzet 38f9498006 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:26:27 +07:00
zarzet 67fc3e5de2 fix: revert AGP 9 to 8.13.2 - Flutter plugins not yet compatible with AGP 9 2026-02-07 20:46:23 +07:00
zarzet f1e6e9253f fix: opt out of AGP 9 newDsl for Flutter compatibility 2026-02-07 20:26:59 +07:00
zarzet 11c612e270 fix: remove kotlin-android plugin for AGP 9 built-in Kotlin support 2026-02-07 20:12:26 +07:00
zarzet cec5e49659 fix(deps): migrate flutter_local_notifications to v20 named params, update changelog with all dependency changes since 3.5.0 2026-02-07 20:02:11 +07:00
Zarz Eleutherius 1dbdb5f2c3 Update VirusTotal badge link in README 2026-02-07 19:57:44 +07:00
zarzet 086511d3e9 perf: unified parallel scheduler, dynamic concurrency 1-5, log truncation + FFmpeg command redaction 2026-02-07 19:57:44 +07:00
zarzet 3d366d21b7 perf: optimize providers, throttle polling, queued settings save, remove dead screens 2026-02-07 19:57:44 +07:00
zarzet 35f412dbd2 perf: replace PaletteService with blurred cover background, bump v3.5.1 2026-02-07 19:57:44 +07:00
Zarz Eleutherius c167aa0522 Merge pull request #136 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-07 19:56:07 +07:00
Zarz Eleutherius fccb3f3d78 Merge pull request #135 from zarzet/renovate/major-flutter-dependencies
fix(deps): update flutter dependencies (major)
2026-02-07 19:54:49 +07:00
Zarz Eleutherius 3a33283e94 Merge pull request #133 from zarzet/renovate/major-gradle-dependencies
chore(deps): update plugin com.android.application to v9
2026-02-07 19:49:33 +07:00
Zarz Eleutherius c74fb28a3a Merge pull request #131 from zarzet/renovate/actions-setup-java-5.x
chore(deps): update actions/setup-java action to v5
2026-02-07 19:49:18 +07:00
renovate[bot] ea504cc3ed fix(deps): update go dependencies to v2 2026-02-07 12:48:36 +00:00
renovate[bot] 61a2ad258e fix(deps): update flutter dependencies 2026-02-07 12:48:16 +00:00
Zarz Eleutherius ab62a8b1a9 Merge pull request #134 from zarzet/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2
2026-02-07 19:48:04 +07:00
Zarz Eleutherius 479eb1272d Merge pull request #132 from zarzet/renovate/major-github-artifact-actions
chore(deps): update github artifact actions (major)
2026-02-07 19:47:28 +07:00
renovate[bot] d23562e579 chore(deps): update softprops/action-gh-release action to v2 2026-02-07 12:47:07 +00:00
renovate[bot] 541d64bdd0 chore(deps): update plugin com.android.application to v9 2026-02-07 12:47:04 +00:00
renovate[bot] d4f7e6e494 chore(deps): update github artifact actions 2026-02-07 12:47:00 +00:00
renovate[bot] 532c08fe2e chore(deps): update actions/setup-java action to v5 2026-02-07 12:46:56 +00:00
Zarz Eleutherius 704b9674f4 Merge pull request #128 from zarzet/renovate/actions-cache-5.x
chore(deps): update actions/cache action to v5
2026-02-07 19:35:15 +07:00
Zarz Eleutherius 3de94280d2 Merge pull request #129 from zarzet/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2026-02-07 19:34:45 +07:00
Zarz Eleutherius 65897789f6 Merge pull request #130 from zarzet/renovate/actions-setup-go-6.x
chore(deps): update actions/setup-go action to v6
2026-02-07 19:34:29 +07:00
renovate[bot] 5d097c3a95 chore(deps): update actions/setup-go action to v6 2026-02-07 12:32:50 +00:00
renovate[bot] 4023e752a0 chore(deps): update actions/checkout action to v6 2026-02-07 12:32:47 +00:00
Zarz Eleutherius 9a722b1a24 Merge pull request #127 from zarzet/renovate/gradle-dependencies
fix(deps): update gradle dependencies
2026-02-07 19:31:18 +07:00
renovate[bot] 37b4727a29 chore(deps): update actions/cache action to v5 2026-02-07 11:49:57 +00:00
renovate[bot] 2604d0002a fix(deps): update gradle dependencies 2026-02-07 11:49:46 +00:00
Zarz Eleutherius cca337ab31 Merge pull request #125 from zarzet/renovate/go-dependencies
chore(deps): update dependency go to v1.25.7
2026-02-07 18:48:46 +07:00
renovate[bot] bb6e766a09 chore(deps): update dependency go to v1.25.7 2026-02-07 09:14:48 +00:00
Zarz Eleutherius af203ae51f Update VirusTotal badge link in README 2026-02-07 14:44:19 +07:00
zarzet 01cbdde70e Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-02-07 14:39:08 +07:00
Zarz Eleutherius e70ed311ed Merge pull request #123 from zarzet/renovate/com.android.tools-desugar_jdk_libs-2.x
chore(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5
2026-02-07 14:36:30 +07:00
Zarz Eleutherius c732cddf06 Merge pull request #122 from zarzet/renovate/golang.org-x-mobile-digest
chore(deps): update golang.org/x/mobile digest to 1dceadb
2026-02-07 14:36:16 +07:00
zarzet 1f71f957e2 chore: add Renovate config targeting dev branch with automerge 2026-02-07 14:35:37 +07:00
renovate[bot] 757c5fab19 chore(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 2026-02-07 07:32:37 +00:00
renovate[bot] cfa537db1f chore(deps): update golang.org/x/mobile digest to 1dceadb 2026-02-07 07:32:34 +00:00
Zarz Eleutherius 6e7c766945 Fix VirusTotal badge link formatting in README 2026-02-04 18:29:22 +07:00
Zarz Eleutherius 55b457a4c0 Update VirusTotal badge link in README
Updated VirusTotal badge link in README.md.
2026-02-04 18:28:50 +07:00
Zarz Eleutherius 423695c24d Update VirusTotal badge link in README.md 2026-02-01 22:00:38 +07:00
87 changed files with 17897 additions and 9339 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 2 # Need previous commit to compare fetch-depth: 2 # Need previous commit to compare
+16 -16
View File
@@ -60,23 +60,23 @@ jobs:
df -h df -h
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v5
with: with:
distribution: "temurin" distribution: "temurin"
java-version: "17" java-version: "17"
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: "1.25" go-version: "1.25"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds # Cache Gradle for faster builds
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: | path: |
~/.gradle/caches ~/.gradle/caches
@@ -158,7 +158,7 @@ jobs:
ls -la ls -la
- name: Upload APK artifact - name: Upload APK artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: android-apk name: android-apk
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
@@ -169,17 +169,17 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version: "1.25" go-version: "1.25"
cache-dependency-path: go_backend/go.sum cache-dependency-path: go_backend/go.sum
# Cache CocoaPods # Cache CocoaPods
- name: Cache CocoaPods - name: Cache CocoaPods
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ios/Pods path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }} key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
@@ -295,7 +295,7 @@ jobs:
fi fi
- name: Upload IPA artifact - name: Upload IPA artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v6
with: with:
name: ios-ipa name: ios-ipa
path: build/ios/ipa/SpotiFLAC-*.ipa path: build/ios/ipa/SpotiFLAC-*.ipa
@@ -308,7 +308,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Extract changelog for version - name: Extract changelog for version
id: changelog id: changelog
@@ -338,13 +338,13 @@ jobs:
cat /tmp/changelog.txt cat /tmp/changelog.txt
- name: Download Android APK - name: Download Android APK
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: android-apk name: android-apk
path: ./release path: ./release
- name: Download iOS IPA - name: Download iOS IPA
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: ios-ipa name: ios-ipa
path: ./release path: ./release
@@ -385,7 +385,7 @@ jobs:
cat /tmp/release_body.txt cat /tmp/release_body.txt
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
with: with:
tag_name: ${{ needs.get-version.outputs.version }} tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }} name: SpotiFLAC ${{ needs.get-version.outputs.version }}
@@ -403,16 +403,16 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Download Android APK - name: Download Android APK
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: android-apk name: android-apk
path: ./release path: ./release
- name: Download iOS IPA - name: Download iOS IPA
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
name: ios-ipa name: ios-ipa
path: ./release path: ./release
+192
View File
@@ -1,5 +1,197 @@
# Changelog # Changelog
## [3.6.0] - 2026-02-09
### Highlights
- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services
- Opus 256kbps (recommended) or MP3 320kbps quality options
- Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC
- Lyrics fetching from lrclib.net with embed and external .lrc support
- Works as fallback when Tidal/Qobuz/Amazon downloads fail
- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus)
- Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC
- Advanced fields: Label, Copyright, Composer, Comment
- FLAC: native Go writer, MP3/Opus: FFmpeg-based writer
- UI refreshes in-place after save without needing to re-open the screen
- iOS and Android support
### Added
- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen
- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song
- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg)
- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file
- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion
- SpotubeDL as fallback Cobalt proxy when primary API fails
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during YouTube download
- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode)
- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC
### Changed
- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead
- Simplified download service picker by removing dead lossy format code
- Removed Amazon from download settings UI (now only used as automatic fallback)
- Cleaned up dead disabled-chip code in download service selector
### Fixed
- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests
- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility
- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests
- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups
- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding)
- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files
- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly)
- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus)
- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath`
- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard
- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading
- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label)
---
## [3.5.3] - 2026-02-09
### Added
- CSV import flow now includes a new option: **Skip already downloaded songs** before enqueueing tracks
- Added regression test suite for cross-script matching behavior in Go backend (`go_backend/matching_test.go`)
### Changed
- CSV import confirmation dialog now supports filtering out tracks already present in download history (matched by Spotify ID and ISRC)
- CSV import enqueue feedback now reports added/skipped counts when duplicate downloads are skipped
- Home search now prioritizes **Recent Access** when search field is focused with empty input, even if old search results still exist in memory
- Search filter/result sections are now hidden while Recent Access mode is active to avoid stale-result overlap
- Recent Access now shows a localized empty-state message when no recent items are available
- Normalized collapsing AppBar top inset across iOS/Android so header height/animation stays visually consistent on Apple devices
- Storage & Cache UX improved: `Clear all cache` now preserves web/runtime cache by default (optional), with explicit warnings/actions for runtime cache resets
- Local library settings now include a display count for tracks excluded because they already exist in download history
- Responsive layout tuning applied across key screens to reduce hardcoded-height overflow issues on smaller devices
### Fixed
- Fixed false-positive cross-script matching in Qobuz/Tidal where unrelated titles/artists in different scripts could be incorrectly accepted
- Cross-script title/artist matching now requires transliteration-aware normalization and strict similarity checks instead of auto-accepting script differences
- Qobuz metadata fallback no longer scans all results when zero title matches are found; title verification is now required
- Qobuz metadata final validation now rejects results when title does not match expected track name
- Fixed Home search regression where Recent Access panel could disappear after previous searches
- Fixed Local Library card/layout crash caused by `Flex` usage under unbounded height constraints
- Hardened FFmpeg metadata embedding temp-file naming to prevent rare collisions during parallel downloads/fallback flows (Qobuz → Tidal) that could cause missing embedded metadata
- Fixed SAF external lyrics naming where some providers saved `.lrc` files as `.lrc.txt`; LRC export now uses neutral MIME to preserve `.lrc` extension
## [3.5.2] - 2026-02-08
### Performance
- Home tab search result sections are now virtualized with `SliverList` (lazy item build) instead of eager `Column` rendering, reducing frame drops on large result sets
- Home tab now narrows Riverpod subscriptions using field-level `select(...)` for search/provider state to reduce unnecessary full-tab rebuilds
- Search provider dropdown now watches only required fields (`searchProvider`, `metadataSource`, `extensions`) instead of full provider states
- Track row rendering in Home search now receives precomputed thumbnail sizing/local-library flags from parent to avoid repeated per-item provider watches
- Removed thumbnail `debugPrint` calls inside track row `build()` to reduce runtime overhead during scrolling/rebuilds
- Queue tab root subscription no longer watches full queue item list; it now watches only queue presence (`items.isNotEmpty`) to avoid full Library UI rebuilds on every progress tick
- Queue download header/list rendering has been isolated into dedicated `Consumer` slivers; header now watches only queue length (`items.length`) while item list watches queue item updates
- Queue filter/sort computations are now centralized and memoized per filter mode within a build pass (`all`/`albums`/`singles`), reducing repeated list transforms for chip counts and page content
- Selection bottom bar content is now computed only when selection mode is active, removing hidden-state heavy list preparation
- File existence checks in queue/library rows now use per-path `ValueNotifier` + `ValueListenableBuilder` updates instead of triggering global `setState`, reducing unnecessary whole-tab repaints
### Changed
- Replaced date range filter with sorting options in Library tab: Latest, Oldest, A-Z, Z-A
- Sorting applies to all views: unified items, downloaded albums, and local library albums
- Local library items now use file modification time (`fileModTime`) for sorting instead of scan time, providing more accurate chronological ordering
- Removed redundant manual "Export Failed Downloads" button from Library UI (auto-export setting in Settings is sufficient)
- Library filters (quality, format, source) now correctly apply to album tabs and update tab chip counts (All/Albums/Singles)
### Fixed
- Fixed local library scan crashing on Samsung One UI devices due to MediaStore URI mismatch in SAF tree traversal
- Added MediaStore URI fallback in SAF file reader: when SAF permission is denied for Samsung-returned MediaStore URIs, automatically retries using READ_MEDIA_AUDIO permission
- Hardened SAF scan with per-directory and per-file error handling: scan now skips problematic files instead of aborting entirely
- Added visited directory tracking to prevent infinite loops from circular SAF references
- Fixed metadata enrichment cascading failure after one queued download fails: metadata APIs (Deezer, SongLink, Spotify) now use isolated `metadataTransport` so failed download connections cannot poison metadata requests
- Added immediate connection cleanup on every download failure path (error response and exception), not only periodic cleanup every N downloads
- Fixed incremental SAF scan edge case where `lastModified()` failure could misclassify existing files as removed (`removedUris`)
- Fixed tracks marked "In Library" still showing active download button - download button now shows as completed (checkmark) for local library tracks across all screens (album, playlist, artist, home/search)
- Fixed FFmpeg M4A-to-FLAC conversion erroneously triggered on already-existing FLAC files when re-downloading duplicates via Tidal
- Fixed SAF download creating empty artist/album folders when re-downloading duplicate tracks; directory is now only created after confirming the file does not already exist
## [3.5.1] - 2026-02-08
### Performance
- Removed PaletteService (palette_generator) from all screens for faster navigation and reduced memory usage
- Album, Playlist, Downloaded Album, Local Album, and Track Metadata screens now use blurred cover art as header background instead of dominant color extraction
- Removed `palette_generator` dependency
- App startup now renders immediately (`runApp`) while service initialization runs asynchronously in eager init
- Main shell provider subscriptions now use field-level `select(...)` to reduce unnecessary rebuilds
- Settings persistence now uses single-flight + queued save coalescing to avoid redundant disk writes
- Progress polling cadence adjusted to 800ms for download queue, local library scan progress, and Go log polling
- Android foreground download service progress updates are throttled (change-based updates + 5s heartbeat)
- SAF history repair is now batched (`20` items per batch) and capped per launch (`60`) to reduce startup I/O spikes
- Incremental library scan now builds final item list in-memory instead of reloading from database
- Local cover images in queue/library use direct `Image.file` with `errorBuilder` instead of `FutureBuilder` existence check
- CSV parser `_parseLine` rewritten: correct escaped-quote handling, no quote characters in output
- Removed unused legacy screen files (`home_screen.dart`, `queue_screen.dart`, `settings_screen.dart`, `settings_tab.dart`)
- Incremental local library scan now merges delta results in-memory and sorts once, avoiding full-state reload churn
- Queue local cover rendering now uses direct `Image.file` + `errorBuilder` (removed repeated async file-exists checks)
### Added
- Auto-cleanup orphaned downloads on history load (files that no longer exist are automatically removed from history)
### Changed
- Removed legacy screen files that were no longer used after the tab/part refactor:
- `lib/screens/home_screen.dart`
- `lib/screens/queue_screen.dart`
- `lib/screens/settings_screen.dart`
- `lib/screens/settings_tab.dart`
- Concurrent download limit increased from `3` to `5` (settings clamp + Options UI chips now support `1..5`)
- Download queue now uses a single parallel scheduler path; `1` concurrency is handled as parallel-with-limit-1 (no separate sequential engine)
- Download queue now listens to settings updates in real-time so concurrency/output settings stay in sync while queue is active
### Fixed
- CSV parser now correctly handles escaped quotes (`""`) inside quoted fields during import
- Fixed dynamic concurrency update during active downloads: changing limit (e.g. `1 -> 3`) now schedules additional queued items without waiting current active item to finish
- Queue scheduler now re-checks capacity/queued items on short intervals to avoid blocking on long-running single active download
### Dependencies
#### Flutter
- `flutter_local_notifications` 19.x → 20.0.0 (breaking: all positional params converted to named params)
- `connectivity_plus` 6.x → 7.0.0
- `flutter_secure_storage` 9.x → 10.0.0
- Removed `palette_generator` dependency
#### Go
- `go-flac/go-flac` v1.0.0 → v2.0.4
- `go-flac/flacvorbis` v0.2.0 → v2.0.2
- `go-flac/flacpicture` v0.3.0 → v2.0.2
- Go toolchain 1.24 → 1.25.7
#### Android
- Android Gradle Plugin 8.x → 9.0.0
- Kotlin 2.1.x → 2.3.10
- `desugar_jdk_libs` → 2.1.5
- `kotlinx-coroutines-android` → 1.10.2
- `lifecycle-runtime-ktx` → 2.10.0
- `activity-ktx` → 1.12.3
#### CI/CD
- `actions/cache` v4 → v5
- `actions/checkout` v4 → v6
- `actions/setup-go` v5 → v6
- `actions/setup-java` v4 → v5
- `softprops/action-gh-release` v1 → v2
- GitHub artifact actions updated
---
## [3.5.0] - 2026-02-07 ## [3.5.0] - 2026-02-07
### Highlights ### Highlights
+13 -19
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center"> <div align="center">
@@ -24,15 +24,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
<img src="assets/images/4.jpg?v=2" width="200" /> <img src="assets/images/4.jpg?v=2" width="200" />
</p> </p>
## Search Source
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Extensions** | Install additional search providers from the Store |
## Extensions ## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently. Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
@@ -54,15 +45,8 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
## Telegram ## Telegram
<p align="center"> [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
<a href="https://t.me/spotiflac"> [![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflac_chat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
## FAQ ## FAQ
@@ -108,6 +92,16 @@ You are solely responsible for:
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
## API Credits
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
- **Lyrics**: [LRCLib](https://lrclib.net)
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
> [!TIP] > [!TIP]
> >
> **Star Us**, You will receive all release notifications from GitHub without any delay ~ > **Star Us**, You will receive all release notifications from GitHub without any delay ~
+5 -5
View File
@@ -96,13 +96,13 @@ repositories {
} }
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
// Include all AAR and JAR files from libs folder // Include all AAR and JAR files from libs folder
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar")))) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.documentfile:documentfile:1.0.1") implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.9.0") implementation("androidx.activity:activity-ktx:1.12.3")
} }
@@ -424,36 +424,159 @@ class MainActivity: FlutterFragmentActivity() {
return obj.toString() return obj.toString()
} }
private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? { /**
val mime = contentResolver.getType(uri) * Detect whether a content URI belongs to the MediaStore provider.
val nameHint = ( * Samsung One UI may return MediaStore URIs from SAF tree traversal,
DocumentFile.fromSingleUri(this, uri)?.name * which require READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission
?: uri.lastPathSegment * instead of SAF tree permission.
?: "" */
).lowercase(Locale.ROOT) private fun isMediaStoreUri(uri: Uri): Boolean {
val extFromName = when { val authority = uri.authority ?: return false
nameHint.endsWith(".m4a") -> ".m4a" return authority == "media" ||
nameHint.endsWith(".mp3") -> ".mp3" authority.startsWith("media.") ||
nameHint.endsWith(".opus") -> ".opus" authority.contains("media")
nameHint.endsWith(".flac") -> ".flac" }
/**
* Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE.
*/
private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String {
// Try DISPLAY_NAME first
try {
contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val name = cursor.getString(0)?.lowercase(Locale.ROOT) ?: ""
val ext = extFromFileName(name)
if (ext.isNotBlank()) return ext
}
}
} catch (_: Exception) {}
// Try MIME_TYPE
try {
val mime = contentResolver.getType(uri)
val ext = extFromMimeType(mime)
if (ext.isNotBlank()) return ext
} catch (_: Exception) {}
return fallbackExt ?: ""
}
private fun extFromFileName(name: String): String {
return when {
name.endsWith(".m4a") -> ".m4a"
name.endsWith(".mp3") -> ".mp3"
name.endsWith(".opus") -> ".opus"
name.endsWith(".flac") -> ".flac"
name.endsWith(".ogg") -> ".ogg"
else -> "" else -> ""
} }
val extFromMime = when (mime) { }
private fun extFromMimeType(mime: String?): String {
return when (mime) {
"audio/mp4" -> ".m4a" "audio/mp4" -> ".m4a"
"audio/mpeg" -> ".mp3" "audio/mpeg" -> ".mp3"
"audio/ogg" -> ".opus" "audio/ogg" -> ".opus"
"audio/flac" -> ".flac" "audio/flac" -> ".flac"
else -> "" else -> ""
} }
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "") }
val suffix: String? = if (ext.isNotBlank()) ext else null
val tempFile = File.createTempFile("saf_", suffix, cacheDir) private fun copyUriToTemp(uri: Uri, fallbackExt: String? = null): String? {
contentResolver.openInputStream(uri)?.use { input -> var tempFile: File? = null
FileOutputStream(tempFile).use { output -> var success = false
input.copyTo(output)
try {
val mime = try { contentResolver.getType(uri) } catch (_: Exception) { null }
val nameHint = (
try { DocumentFile.fromSingleUri(this, uri)?.name } catch (_: Exception) { null }
?: uri.lastPathSegment
?: ""
).lowercase(Locale.ROOT)
val extFromName = extFromFileName(nameHint)
val extFromMime = extFromMimeType(mime)
val ext = if (extFromName.isNotBlank()) extFromName else if (extFromMime.isNotBlank()) extFromMime else (fallbackExt ?: "")
val suffix: String? = if (ext.isNotBlank()) ext else null
tempFile = File.createTempFile("saf_", suffix, cacheDir)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
} ?: return null
success = true
return tempFile.absolutePath
} catch (e: SecurityException) {
// SAF permission denied - try MediaStore fallback for Samsung One UI
// which may return MediaStore URIs from SAF tree traversal
if (isMediaStoreUri(uri)) {
android.util.Log.d(
"SpotiFLAC",
"SAF denied for MediaStore URI, trying MediaStore fallback: $uri",
)
val result = copyMediaStoreUriToTemp(uri, fallbackExt)
if (result != null) {
success = true
return result
}
} }
} ?: return null android.util.Log.w(
return tempFile.absolutePath "SpotiFLAC",
"SAF read denied for $uri: ${e.message}",
)
return null
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Failed copying SAF uri $uri to temp: ${e.message}",
)
return null
} finally {
if (!success) {
try {
tempFile?.delete()
} catch (_: Exception) {}
}
}
}
/**
* Fallback for Samsung One UI: read a MediaStore content URI using
* READ_MEDIA_AUDIO / READ_EXTERNAL_STORAGE permission instead of SAF.
* This handles the case where SAF tree traversal returns MediaStore URIs
* that the SAF document provider cannot access.
*/
private fun copyMediaStoreUriToTemp(uri: Uri, fallbackExt: String?): String? {
var tempFile: File? = null
try {
val ext = resolveMediaStoreExt(uri, fallbackExt)
val suffix: String? = if (ext.isNotBlank()) ext else null
tempFile = File.createTempFile("ms_", suffix, cacheDir)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
} ?: run {
tempFile.delete()
return null
}
android.util.Log.d(
"SpotiFLAC",
"MediaStore fallback succeeded for $uri",
)
return tempFile.absolutePath
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"MediaStore fallback also failed for $uri: ${e.message}",
)
try { tempFile?.delete() } catch (_: Exception) {}
return null
}
} }
private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean { private fun writeUriFromPath(uri: Uri, srcPath: String): Boolean {
@@ -479,22 +602,30 @@ class MainActivity: FlutterFragmentActivity() {
val relativeDir = req.optString("saf_relative_dir", "") val relativeDir = req.optString("saf_relative_dir", "")
val outputExt = normalizeExt(req.optString("saf_output_ext", "")) val outputExt = normalizeExt(req.optString("saf_output_ext", ""))
val mimeType = mimeTypeForExt(outputExt) val mimeType = mimeTypeForExt(outputExt)
val fileName = buildSafFileName(req, outputExt)
// Check for existing file WITHOUT creating the directory first.
// This prevents empty folders from being created for duplicate downloads.
val existingDir = findDocumentDir(treeUri, relativeDir)
if (existingDir != null) {
val existing = existingDir.findFile(fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
obj.put("file_path", existing.uri.toString())
obj.put("file_name", existing.name ?: fileName)
obj.put("already_exists", true)
return obj.toString()
}
}
// Only create the directory now that we know we need to download
val targetDir = ensureDocumentDir(treeUri, relativeDir) val targetDir = ensureDocumentDir(treeUri, relativeDir)
?: return errorJson("Failed to access SAF directory") ?: return errorJson("Failed to access SAF directory")
val fileName = buildSafFileName(req, outputExt) val existingFile = targetDir.findFile(fileName)
val existing = targetDir.findFile(fileName) val document = existingFile ?: targetDir.createFile(mimeType, fileName)
if (existing != null && existing.isFile && existing.length() > 0) {
val obj = JSONObject()
obj.put("success", true)
obj.put("message", "File already exists")
obj.put("file_path", existing.uri.toString())
obj.put("file_name", existing.name ?: fileName)
obj.put("already_exists", true)
return obj.toString()
}
val document = existing ?: targetDir.createFile(mimeType, fileName)
?: return errorJson("Failed to create SAF file") ?: return errorJson("Failed to create SAF file")
val pfd = contentResolver.openFileDescriptor(document.uri, "rw") val pfd = contentResolver.openFileDescriptor(document.uri, "rw")
@@ -547,9 +678,14 @@ class MainActivity: FlutterFragmentActivity() {
resetSafScanProgress() resetSafScanProgress()
safScanCancel = false safScanCancel = false
safScanActive = true safScanActive = true
updateSafScanProgress {
it.currentFile = "Scanning folders..."
}
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Pair<DocumentFile, String>>() val audioFiles = mutableListOf<Pair<DocumentFile, String>>()
val visitedDirUris = mutableSetOf<String>()
var traversalErrors = 0
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque() val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
queue.add(root to "") queue.add(root to "")
@@ -561,22 +697,52 @@ class MainActivity: FlutterFragmentActivity() {
} }
val (dir, path) = queue.removeFirst() val (dir, path) = queue.removeFirst()
for (child in dir.listFiles()) { val dirUri = dir.uri.toString()
if (!visitedDirUris.add(dirUri)) {
continue
}
val children = try {
dir.listFiles()
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF scan: failed listing directory $dirUri: ${e.message}",
)
continue
}
for (child in children) {
if (safScanCancel) { if (safScanCancel) {
updateSafScanProgress { it.isComplete = true } updateSafScanProgress { it.isComplete = true }
return "[]" return "[]"
} }
if (child.isDirectory) { try {
val childName = child.name ?: continue if (child.isDirectory) {
val childPath = if (path.isBlank()) childName else "$path/$childName" val childName = child.name ?: continue
queue.add(child to childPath) val childPath = if (path.isBlank()) childName else "$path/$childName"
} else if (child.isFile) { val childUri = child.uri.toString()
val name = child.name ?: continue if (childUri == dirUri || visitedDirUris.contains(childUri)) {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) continue
if (ext.isNotBlank() && supportedExt.contains(".$ext")) { }
audioFiles.add(child to path) queue.add(child to childPath)
} else if (child.isFile) {
val name = child.name ?: continue
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
audioFiles.add(child to path)
}
} }
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF scan: skipped child under $dirUri: ${e.message}",
)
} }
} }
} }
@@ -595,7 +761,7 @@ class MainActivity: FlutterFragmentActivity() {
val results = JSONArray() val results = JSONArray()
var scanned = 0 var scanned = 0
var errors = 0 var errors = traversalErrors
for ((doc, _) in audioFiles) { for ((doc, _) in audioFiles) {
if (safScanCancel) { if (safScanCancel) {
@@ -603,14 +769,22 @@ class MainActivity: FlutterFragmentActivity() {
return "[]" return "[]"
} }
val name = doc.name ?: "" val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress { updateSafScanProgress {
it.currentFile = name it.currentFile = name
} }
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val tempPath = copyUriToTemp(doc.uri, fallbackExt) val tempPath = try {
copyUriToTemp(doc.uri, fallbackExt)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF scan: failed to copy ${doc.uri}: ${e.message}",
)
null
}
if (tempPath == null) { if (tempPath == null) {
errors++ errors++
} else { } else {
@@ -618,7 +792,7 @@ class MainActivity: FlutterFragmentActivity() {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) { if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson) val obj = JSONObject(metadataJson)
val lastModified = doc.lastModified() val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
obj.put("filePath", doc.uri.toString()) obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified) obj.put("fileModTime", lastModified)
results.put(obj) results.put(obj)
@@ -691,10 +865,15 @@ class MainActivity: FlutterFragmentActivity() {
resetSafScanProgress() resetSafScanProgress()
safScanCancel = false safScanCancel = false
safScanActive = true safScanActive = true
updateSafScanProgress {
it.currentFile = "Scanning folders..."
}
val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg")
val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified val audioFiles = mutableListOf<Triple<DocumentFile, String, Long>>() // doc, path, lastModified
val currentUris = mutableSetOf<String>() val currentUris = mutableSetOf<String>()
val visitedDirUris = mutableSetOf<String>()
var traversalErrors = 0
// Collect all audio files with lastModified // Collect all audio files with lastModified
val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque() val queue: ArrayDeque<Pair<DocumentFile, String>> = ArrayDeque()
@@ -713,7 +892,24 @@ class MainActivity: FlutterFragmentActivity() {
} }
val (dir, path) = queue.removeFirst() val (dir, path) = queue.removeFirst()
for (child in dir.listFiles()) { val dirUri = dir.uri.toString()
if (!visitedDirUris.add(dirUri)) {
continue
}
val children = try {
dir.listFiles()
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: failed listing directory $dirUri: ${e.message}",
)
continue
}
for (child in children) {
if (safScanCancel) { if (safScanCancel) {
updateSafScanProgress { it.isComplete = true } updateSafScanProgress { it.isComplete = true }
val result = JSONObject() val result = JSONObject()
@@ -725,24 +921,44 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString() return result.toString()
} }
if (child.isDirectory) { try {
val childName = child.name ?: continue if (child.isDirectory) {
val childPath = if (path.isBlank()) childName else "$path/$childName" val childName = child.name ?: continue
queue.add(child to childPath) val childPath = if (path.isBlank()) childName else "$path/$childName"
} else if (child.isFile) { val childUri = child.uri.toString()
val name = child.name ?: continue if (childUri == dirUri || visitedDirUris.contains(childUri)) {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) continue
if (ext.isNotBlank() && supportedExt.contains(".$ext")) { }
queue.add(child to childPath)
} else if (child.isFile) {
// Mark file as present first so it cannot be mis-classified as removed
// when provider-specific metadata calls (e.g., lastModified) fail.
val uriStr = child.uri.toString() val uriStr = child.uri.toString()
val lastModified = child.lastModified()
currentUris.add(uriStr) currentUris.add(uriStr)
// Check if file is new or modified val name = child.name ?: continue
val existingModified = existingFiles[uriStr] val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
if (existingModified == null || existingModified != lastModified) { if (ext.isNotBlank() && supportedExt.contains(".$ext")) {
audioFiles.add(Triple(child, path, lastModified)) val existingModified = existingFiles[uriStr]
val lastModified = try {
child.lastModified()
} catch (_: Exception) {
existingModified ?: 0L
}
// Check if file is new or modified
if (existingModified == null || existingModified != lastModified) {
audioFiles.add(Triple(child, path, lastModified))
}
} }
} }
} catch (e: Exception) {
traversalErrors++
updateSafScanProgress { it.errorCount = traversalErrors }
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: skipped child under $dirUri: ${e.message}",
)
} }
} }
} }
@@ -772,7 +988,7 @@ class MainActivity: FlutterFragmentActivity() {
val results = JSONArray() val results = JSONArray()
var scanned = 0 var scanned = 0
var errors = 0 var errors = traversalErrors
for ((doc, _, lastModified) in audioFiles) { for ((doc, _, lastModified) in audioFiles) {
if (safScanCancel) { if (safScanCancel) {
@@ -786,14 +1002,22 @@ class MainActivity: FlutterFragmentActivity() {
return result.toString() return result.toString()
} }
val name = doc.name ?: "" val name = try { doc.name ?: "" } catch (_: Exception) { "" }
updateSafScanProgress { updateSafScanProgress {
it.currentFile = name it.currentFile = name
} }
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val tempPath = copyUriToTemp(doc.uri, fallbackExt) val tempPath = try {
copyUriToTemp(doc.uri, fallbackExt)
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"SAF incremental scan: failed to copy ${doc.uri}: ${e.message}",
)
null
}
if (tempPath == null) { if (tempPath == null) {
errors++ errors++
} else { } else {
@@ -801,9 +1025,10 @@ class MainActivity: FlutterFragmentActivity() {
val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) val metadataJson = Gobackend.readAudioMetadataJSON(tempPath)
if (metadataJson.isNotBlank()) { if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson) val obj = JSONObject(metadataJson)
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
obj.put("filePath", doc.uri.toString()) obj.put("filePath", doc.uri.toString())
obj.put("fileModTime", lastModified) obj.put("fileModTime", safeLastModified)
obj.put("lastModified", lastModified) obj.put("lastModified", safeLastModified)
results.put(obj) results.put(obj)
} else { } else {
errors++ errors++
@@ -1372,7 +1597,182 @@ class MainActivity: FlutterFragmentActivity() {
"readFileMetadata" -> { "readFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.readFileMetadata(filePath) try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.readFileMetadata(tempPath)
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.readFileMetadata(filePath)
}
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "readFileMetadata failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"editFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
val raw = Gobackend.editFileMetadata(tempPath, metadataJson)
val obj = JSONObject(raw)
val method = obj.optString("method", "")
if (method == "ffmpeg") {
// MP3/Opus: Dart needs to FFmpeg the temp file, then call writeTempToSaf
obj.put("temp_path", tempPath)
obj.put("saf_uri", filePath)
return@withContext obj.toString()
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
}
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
throw e
}
} else {
Gobackend.editFileMetadata(filePath, metadataJson)
}
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "editFileMetadata failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"writeTempToSaf" -> {
val tempPath = call.argument<String>("temp_path") ?: ""
val safUri = call.argument<String>("saf_uri") ?: ""
val response = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(safUri)
if (writeUriFromPath(uri, tempPath)) {
"""{"success":true}"""
} else {
"""{"success":false,"error":"Failed to write back to SAF"}"""
}
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
}
result.success(response)
}
"downloadCoverToFile" -> {
val coverUrl = call.argument<String>("cover_url") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val maxQuality = call.argument<Boolean>("max_quality") ?: true
val response = withContext(Dispatchers.IO) {
try {
Gobackend.downloadCoverToFile(coverUrl, outputPath, maxQuality)
"""{"success":true}"""
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"extractCoverToFile" -> {
val audioPath = call.argument<String>("audio_path") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
if (audioPath.startsWith("content://")) {
val uri = Uri.parse(audioPath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"success":false,"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.extractCoverToFile(tempPath, outputPath)
"""{"success":true}"""
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.extractCoverToFile(audioPath, outputPath)
"""{"success":true}"""
}
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"fetchAndSaveLyrics" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
val durationMs = call.argument<Long>("duration_ms") ?: 0L
val outputPath = call.argument<String>("output_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath)
"""{"success":true}"""
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"reEnrichFile" -> {
val requestJson = call.argument<String>("request_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
val reqObj = JSONObject(requestJson)
val filePath = reqObj.optString("file_path", "")
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
// Replace file_path with temp path for Go
reqObj.put("file_path", tempPath)
val raw = Gobackend.reEnrichFile(reqObj.toString())
val obj = JSONObject(raw)
if (obj.has("error")) {
return@withContext raw
}
val method = obj.optString("method", "")
if (method == "ffmpeg") {
// MP3/Opus: Dart handles FFmpeg on temp file, then writes back
obj.put("temp_path", tempPath)
obj.put("saf_uri", filePath)
return@withContext obj.toString()
// temp file NOT deleted - Dart cleans up after FFmpeg + writeTempToSaf
}
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
throw e
}
} else {
Gobackend.reEnrichFile(requestJson)
}
} catch (e: Exception) {
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
} }
result.success(response) result.success(response)
} }
@@ -1702,6 +2102,15 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"downloadFromYouTube" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadFromYouTube(json)
}
}
result.success(response)
}
"enrichTrackWithExtension" -> { "enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: "" val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}" val trackJson = call.argument<String>("track") ?: "{}"
@@ -2024,7 +2433,22 @@ class MainActivity: FlutterFragmentActivity() {
"readAudioMetadata" -> { "readAudioMetadata" -> {
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.readAudioMetadataJSON(filePath) try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.readAudioMetadataJSON(tempPath)
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.readAudioMetadataJSON(filePath)
}
} catch (e: Exception) {
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
} }
result.success(response) result.success(response)
} }
+1 -1
View File
@@ -22,7 +22,7 @@ subprojects {
} }
// Add desugaring dependency to all Android subprojects // Add desugaring dependency to all Android subprojects
project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.4") project.dependencies.add("coreLibraryDesugaring", "com.android.tools:desugar_jdk_libs:2.1.5")
} }
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.0" apply false id("org.jetbrains.kotlin.android") version "2.2.21" apply false
} }
include(":app") include(":app")
+68
View File
@@ -23,6 +23,10 @@ type AudioMetadata struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
Label string
Copyright string
Composer string
Comment string
} }
// MP3Quality represents MP3 specific quality info // MP3Quality represents MP3 specific quality info
@@ -171,6 +175,12 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber = parseTrackNumber(value)
case "TPA": case "TPA":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "TCM":
metadata.Composer = value
case "TPB":
metadata.Label = value
case "TCR":
metadata.Copyright = value
} }
pos += 6 + frameSize pos += 6 + frameSize
@@ -277,6 +287,16 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "TSRC": case "TSRC":
metadata.ISRC = value metadata.ISRC = value
case "TCOM":
metadata.Composer = value
case "TPUB":
metadata.Label = value
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractCommentFrame(frameData); v != "" {
metadata.Comment = v
}
} }
pos += 10 + frameSize pos += 10 + frameSize
@@ -339,6 +359,46 @@ func extractTextFrame(data []byte) string {
} }
} }
// extractCommentFrame parses an ID3v2 COMM frame.
// Format: encoding(1) + language(3) + description(null-terminated) + text
func extractCommentFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
// skip 3-byte language code
rest := data[4:]
// find null terminator separating description from text
var text []byte
switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
// re-prepend encoding byte so extractTextFrame can decode properly
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
func decodeUTF16(data []byte) string { func decodeUTF16(data []byte) string {
if len(data) < 2 { if len(data) < 2 {
return "" return ""
@@ -779,6 +839,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "ISRC": case "ISRC":
metadata.ISRC = value metadata.ISRC = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
} }
} }
} }
+1 -1
View File
@@ -47,7 +47,7 @@ var (
func GetDeezerClient() *DeezerClient { func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() { deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{ deezerClient = &DeezerClient{
httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile), httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry), searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry), albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry), artistCache: make(map[string]*cacheEntry),
+612 -24
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"time" "time"
@@ -267,6 +268,24 @@ func DownloadTrack(requestJSON string) (string, error) {
} }
} }
err = amazonErr err = amazonErr
case "youtube":
youtubeResult, youtubeErr := downloadFromYouTube(req)
if youtubeErr == nil {
result = DownloadResult{
FilePath: youtubeResult.FilePath,
BitDepth: 0, // Lossy format, no bit depth
SampleRate: 0, // Lossy format
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
}
err = youtubeErr
default: default:
return errorResponse("Unknown service: " + req.Service) return errorResponse("Unknown service: " + req.Service)
} }
@@ -538,34 +557,106 @@ func CleanupConnections() {
} }
func ReadFileMetadata(filePath string) (string, error) { func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath) lower := strings.ToLower(filePath)
if err != nil { isFlac := strings.HasSuffix(lower, ".flac")
return "", fmt.Errorf("failed to read metadata: %w", err) isMp3 := strings.HasSuffix(lower, ".mp3")
} isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
quality, qualityErr := GetAudioQuality(filePath)
duration := 0
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
result := map[string]interface{}{ result := map[string]interface{}{
"title": metadata.Title, "title": "",
"artist": metadata.Artist, "artist": "",
"album": metadata.Album, "album": "",
"album_artist": metadata.AlbumArtist, "album_artist": "",
"date": metadata.Date, "date": "",
"track_number": metadata.TrackNumber, "track_number": 0,
"disc_number": metadata.DiscNumber, "disc_number": 0,
"isrc": metadata.ISRC, "isrc": "",
"lyrics": metadata.Lyrics, "lyrics": "",
"duration": duration, "genre": "",
"label": "",
"copyright": "",
"composer": "",
"comment": "",
"duration": 0,
} }
if qualityErr == nil { if isFlac {
result["bit_depth"] = quality.BitDepth metadata, err := ReadMetadata(filePath)
result["sample_rate"] = quality.SampleRate if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
} else if isMp3 {
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetMP3Quality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else if isOgg {
meta, err := ReadOggVorbisComments(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
@@ -576,6 +667,66 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// EditFileMetadata writes metadata to an audio file.
// For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
if isFlac {
trackNum := 0
discNum := 0
if v, ok := fields["track_number"]; ok && v != "" {
fmt.Sscanf(v, "%d", &trackNum)
}
if v, ok := fields["disc_number"]; ok && v != "" {
fmt.Sscanf(v, "%d", &discNum)
}
meta := Metadata{
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
Date: fields["date"],
TrackNumber: trackNum,
DiscNumber: discNum,
ISRC: fields["isrc"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
}
if err := EmbedMetadata(filePath, meta, ""); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{
"success": true,
"method": "ffmpeg",
"fields": fields,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
func SetDownloadDirectory(path string) error { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
} }
@@ -1074,6 +1225,443 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
// DownloadFromYouTube downloads a track from YouTube via Cobalt API
// This is a lossy-only provider (Opus 256kbps or MP3 320kbps)
// It does NOT participate in the lossless fallback chain
func DownloadFromYouTube(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
req.OutputPath = strings.TrimSpace(req.OutputPath)
req.OutputExt = strings.TrimSpace(req.OutputExt)
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
youtubeResult, err := downloadFromYouTube(req)
if err != nil {
return errorResponse(err.Error())
}
resp := DownloadResponse{
Success: true,
Message: "Downloaded from YouTube",
FilePath: youtubeResult.FilePath,
Service: "youtube",
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter)
func IsYouTubeURLExport(urlStr string) bool {
return IsYouTubeURL(urlStr)
}
// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter)
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
return ExtractYouTubeVideoID(urlStr)
}
// ==================== COVER & LYRICS SAVE ====================
// DownloadCoverToFile downloads cover art from URL and saves to outputPath.
// If maxQuality is true, upgrades to highest available resolution.
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
data, err := downloadCoverToMemory(coverURL, maxQuality)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Saved cover art to: %s (%d KB)\n", outputPath, len(data)/1024)
return nil
}
// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath.
func ExtractCoverToFile(audioPath string, outputPath string) error {
lower := strings.ToLower(audioPath)
var coverData []byte
var err error
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
coverData, _, err = extractOggCoverArt(audioPath)
} else {
return fmt.Errorf("unsupported audio format for cover extraction")
}
if err != nil {
return fmt.Errorf("failed to extract cover: %w", err)
}
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
return nil
}
// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file.
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error {
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return fmt.Errorf("lyrics not found: %w", err)
}
if lyrics.Instrumental {
return fmt.Errorf("track is instrumental, no lyrics available")
}
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
if lrcContent == "" {
return fmt.Errorf("failed to generate LRC content")
}
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
return nil
}
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) {
var req struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err)
}
if req.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
// When search_online is true, search for metadata from internet
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
found := false
// 1) Try Deezer first (reliable, no credentials needed)
GoLog("[ReEnrich] Trying Deezer search...\n")
deezerClient := GetDeezerClient()
{
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track")
cancel()
if err == nil && len(deezerResults.Tracks) > 0 {
track := deezerResults.Tracks[0]
GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = "deezer:" + track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Deezer search failed: %v\n", err)
}
}
// 2) Try extension metadata providers (spotify-web etc) if Deezer failed
if !found {
GoLog("[ReEnrich] Trying extension metadata providers...\n")
manager := GetExtensionManager()
extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5)
if extErr == nil && len(extTracks) > 0 {
track := extTracks[0]
GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else {
req.SpotifyID = track.ID
}
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
coverURL := track.ResolvedCoverURL()
if coverURL != "" {
req.CoverURL = coverURL
}
req.DurationMs = int64(track.DurationMS)
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
found = true
} else if extErr != nil {
GoLog("[ReEnrich] Extension search failed: %v\n", extErr)
}
}
// 3) Try Spotify built-in API as last resort (will be deprecated)
if !found {
GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
spotifyClient, spotifyErr := NewSpotifyMetadataClient()
if spotifyErr == nil {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
cancel()
if err == nil && len(results.Tracks) > 0 {
track := results.Tracks[0]
GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Spotify search failed: %v\n", err)
}
} else {
GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
}
}
// Try to get extended metadata (genre, label) from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
}
}
if !found {
GoLog("[ReEnrich] No online match found, using existing metadata\n")
}
}
// Log metadata summary before embedding
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
// Download cover art to temp file
var coverTempPath string
if req.CoverURL != "" {
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
if err != nil {
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
} else {
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
if err == nil {
coverTempPath = tmpFile.Name()
tmpFile.Write(coverData)
tmpFile.Close()
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
}
}
}
// Only cleanup cover temp for FLAC (native embed).
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
cleanupCover := true
defer func() {
if cleanupCover && coverTempPath != "" {
os.Remove(coverTempPath)
}
}()
// Fetch lyrics
var lyricsLRC string
if req.EmbedLyrics {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
if err != nil {
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
} else if !lyrics.Instrumental {
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
GoLog("[ReEnrich] Track is instrumental\n")
}
}
lower := strings.ToLower(req.FilePath)
isFlac := strings.HasSuffix(lower, ".flac")
// Build enriched metadata response for Dart (includes online search results)
enrichedMeta := map[string]interface{}{
"track_name": req.TrackName,
"artist_name": req.ArtistName,
"album_name": req.AlbumName,
"album_artist": req.AlbumArtist,
"release_date": req.ReleaseDate,
"track_number": req.TrackNumber,
"disc_number": req.DiscNumber,
"isrc": req.ISRC,
"genre": req.Genre,
"label": req.Label,
"copyright": req.Copyright,
"cover_url": req.CoverURL,
"spotify_id": req.SpotifyID,
"duration_ms": req.DurationMs,
}
if isFlac {
// Native Go FLAC metadata embedding
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Lyrics: lyricsLRC,
}
if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
result := map[string]interface{}{
"method": "native",
"success": true,
"enriched_metadata": enrichedMeta,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// MP3/Opus: return metadata map for Dart to use FFmpeg
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
result := map[string]interface{}{
"method": "ffmpeg",
"cover_path": coverTempPath,
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": map[string]string{
"TITLE": req.TrackName,
"ARTIST": req.ArtistName,
"ALBUM": req.AlbumName,
"ALBUMARTIST": req.AlbumArtist,
"DATE": req.ReleaseDate,
"ISRC": req.ISRC,
"GENRE": req.Genre,
},
}
if req.TrackNumber > 0 {
result["metadata"].(map[string]string)["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
}
if req.DiscNumber > 0 {
result["metadata"].(map[string]string)["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
}
if req.Label != "" {
result["metadata"].(map[string]string)["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
result["metadata"].(map[string]string)["COPYRIGHT"] = req.Copyright
}
if lyricsLRC != "" {
result["metadata"].(map[string]string)["LYRICS"] = lyricsLRC
result["metadata"].(map[string]string)["UNSYNCEDLYRICS"] = lyricsLRC
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// ==================== EXTENSION SYSTEM ==================== // ==================== EXTENSION SYSTEM ====================
func InitExtensionSystem(extensionsDir, dataDir string) error { func InitExtensionSystem(extensionsDir, dataDir string) error {
+5 -5
View File
@@ -2,15 +2,15 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0 go 1.25.0
toolchain go1.25.6 toolchain go1.25.7
require ( require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0 github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/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 github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
golang.org/x/net v0.49.0 golang.org/x/net v0.49.0
) )
+16 -8
View File
@@ -2,16 +2,18 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -20,12 +22,16 @@ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo= github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU= golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I= golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
@@ -40,3 +46,5 @@ golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+31
View File
@@ -55,6 +55,27 @@ var sharedTransport = &http.Transport{
DisableCompression: true, DisableCompression: true,
} }
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
// Isolated from download traffic so that download failures cannot poison
// the connection pool used by metadata enrichment.
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
}
var sharedClient = &http.Client{ var sharedClient = &http.Client{
Transport: sharedTransport, Transport: sharedTransport,
Timeout: DefaultTimeout, Timeout: DefaultTimeout,
@@ -72,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
} }
} }
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: metadataTransport,
Timeout: timeout,
}
}
func GetSharedClient() *http.Client { func GetSharedClient() *http.Client {
return sharedClient return sharedClient
} }
@@ -82,6 +112,7 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() { func CloseIdleConnections() {
sharedTransport.CloseIdleConnections() sharedTransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
} }
// Also checks for ISP blocking on errors // Also checks for ISP blocking on errors
+17 -2
View File
@@ -22,6 +22,11 @@ type LogBuffer struct {
loggingEnabled bool loggingEnabled bool
} }
const (
defaultLogBufferSize = 500
maxLogMessageLength = 500
)
var ( var (
globalLogBuffer *LogBuffer globalLogBuffer *LogBuffer
logBufferOnce sync.Once logBufferOnce sync.Once
@@ -30,14 +35,22 @@ var (
func GetLogBuffer() *LogBuffer { func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() { logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{ globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, 1000), entries: make([]LogEntry, 0, defaultLogBufferSize),
maxSize: 1000, maxSize: defaultLogBufferSize,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings) loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
} }
}) })
return globalLogBuffer return globalLogBuffer
} }
func truncateLogMessage(message string) string {
runes := []rune(message)
if len(runes) <= maxLogMessageLength {
return message
}
return string(runes[:maxLogMessageLength]) + "...[truncated]"
}
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
@@ -58,6 +71,8 @@ func (lb *LogBuffer) Add(level, tag, message string) {
return return
} }
message = truncateLogMessage(message)
entry := LogEntry{ entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"), Timestamp: time.Now().Format("15:04:05.000"),
Level: level, Level: level,
+27 -3
View File
@@ -9,9 +9,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/go-flac/flacpicture" "github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis" "github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac" "github.com/go-flac/go-flac/v2"
) )
type Metadata struct { type Metadata struct {
@@ -29,6 +29,8 @@ type Metadata struct {
Genre string Genre string
Label string Label string
Copyright string Copyright string
Composer string
Comment string
} }
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
@@ -98,6 +100,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "COPYRIGHT", metadata.Copyright) setComment(cmt, "COPYRIGHT", metadata.Copyright)
} }
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -206,6 +216,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "COPYRIGHT", metadata.Copyright) setComment(cmt, "COPYRIGHT", metadata.Copyright)
} }
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -292,6 +310,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
metadata.Date = getComment(cmt, "YEAR") metadata.Date = getComment(cmt, "YEAR")
} }
metadata.Genre = getComment(cmt, "GENRE")
metadata.Label = getComment(cmt, "ORGANIZATION")
metadata.Copyright = getComment(cmt, "COPYRIGHT")
metadata.Composer = getComment(cmt, "COMPOSER")
metadata.Comment = getComment(cmt, "COMMENT")
break break
} }
} }
+144 -13
View File
@@ -15,18 +15,21 @@ type SongLinkClient struct {
} }
type TrackAvailability struct { type TrackAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"` Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"` Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"` Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"` YouTube bool `json:"youtube"`
AmazonURL string `json:"amazon_url,omitempty"` TidalURL string `json:"tidal_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"` DeezerURL string `json:"deezer_url,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"` YouTubeURL string `json:"youtube_url,omitempty"`
TidalID string `json:"tidal_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
YouTubeID string `json:"youtube_id,omitempty"`
} }
var ( var (
@@ -37,7 +40,7 @@ var (
func NewSongLinkClient() *SongLinkClient { func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() { songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{ globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), client: NewMetadataHTTPClient(SongLinkTimeout),
} }
}) })
return globalSongLinkClient return globalSongLinkClient
@@ -119,6 +122,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
} }
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
// Fallback to regular youtube if youtubeMusic not available
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -246,6 +265,52 @@ func extractTidalIDFromURL(tidalURL string) string {
return "" return ""
} }
// extractYouTubeIDFromURL extracts YouTube video ID from URL
// URL formats:
// - https://www.youtube.com/watch?v=VIDEO_ID
// - https://youtu.be/VIDEO_ID
// - https://music.youtube.com/watch?v=VIDEO_ID
func extractYouTubeIDFromURL(youtubeURL string) string {
if youtubeURL == "" {
return ""
}
// Handle youtu.be short URLs
if strings.Contains(youtubeURL, "youtu.be/") {
parts := strings.Split(youtubeURL, "youtu.be/")
if len(parts) >= 2 {
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
if idx := strings.Index(idPart, "&"); idx > 0 {
idPart = idPart[:idx]
}
return strings.TrimSpace(idPart)
}
}
// Handle youtube.com URLs with ?v= parameter
parsed, err := url.Parse(youtubeURL)
if err != nil {
return ""
}
if v := parsed.Query().Get("v"); v != "" {
return v
}
// Handle /embed/ format
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0]
}
}
return ""
}
// isNumeric is defined in library_scan.go // isNumeric is defined in library_scan.go
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
@@ -261,6 +326,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
return availability.DeezerID, nil return availability.DeezerID, nil
} }
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
// AlbumAvailability represents album availability on different platforms // AlbumAvailability represents album availability on different platforms
type AlbumAvailability struct { type AlbumAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
@@ -441,6 +520,19 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -528,6 +620,19 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -584,6 +689,20 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
return availability.AmazonURL, nil return availability.AmazonURL, nil
} }
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
@@ -652,6 +771,18 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
+1 -1
View File
@@ -114,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano()) src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{ c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second), httpClient: NewMetadataHTTPClient(15 * time.Second),
clientID: clientID, clientID: clientID,
clientSecret: clientSecret, clientSecret: clientSecret,
rng: rand.New(src), rng: rand.New(src),
+566
View File
@@ -0,0 +1,566 @@
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
package gobackend
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type YouTubeDownloader struct {
client *http.Client
apiURL string
mu sync.Mutex
}
var (
globalYouTubeDownloader *YouTubeDownloader
youtubeDownloaderOnce sync.Once
)
type YouTubeQuality string
const (
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
)
type CobaltRequest struct {
URL string `json:"url"`
AudioBitrate string `json:"audioBitrate,omitempty"`
AudioFormat string `json:"audioFormat,omitempty"`
DownloadMode string `json:"downloadMode,omitempty"`
FilenameStyle string `json:"filenameStyle,omitempty"`
DisableMetadata bool `json:"disableMetadata,omitempty"`
}
type CobaltResponse struct {
Status string `json:"status"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
Error *struct {
Code string `json:"code"`
Context *struct {
Service string `json:"service,omitempty"`
Limit int `json:"limit,omitempty"`
} `json:"context,omitempty"`
} `json:"error,omitempty"`
}
type YouTubeDownloadResult struct {
FilePath string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
Format string // "opus" or "mp3"
Bitrate int
LyricsLRC string
CoverData []byte
}
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
apiURL: "https://api.qwkuns.me",
}
})
return globalYouTubeDownloader
}
// SearchYouTube returns a YouTube Music search URL for the given track
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
searchQuery := url.QueryEscape(query)
GoLog("[YouTube] Search query: %s\n", query)
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
return youtubeMusicURL, nil
}
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
y.mu.Lock()
defer y.mu.Unlock()
var audioFormat string
var audioBitrate string
switch quality {
case YouTubeQualityOpus256:
audioFormat = "opus"
audioBitrate = "256"
case YouTubeQualityMP3320:
audioFormat = "mp3"
audioBitrate = "320"
default:
audioFormat = "mp3"
audioBitrate = "320"
}
// Try SpotubeDL first (primary)
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
if extractErr == nil {
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
videoID, audioFormat, audioBitrate)
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
if err == nil {
return resp, nil
}
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
} else {
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
}
// Fallback: direct Cobalt API (api.qwkuns.me)
cobaltURL := toYouTubeMusicURL(youtubeURL)
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
cobaltURL, audioFormat, audioBitrate)
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
if err != nil {
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
}
return resp, nil
}
// requestCobaltDirect sends a download request to the primary Cobalt API.
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{
URL: videoURL,
AudioFormat: audioFormat,
AudioBitrate: audioBitrate,
DownloadMode: "audio",
FilenameStyle: "basic",
DisableMetadata: true,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("cobalt API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
}
var cobaltResp CobaltResponse
if err := json.Unmarshal(body, &cobaltResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
}
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
}
if cobaltResp.URL == "" {
return nil, fmt.Errorf("no download URL in response")
}
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
return &cobaltResp, nil
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
videoID, audioFormat, audioBitrate)
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("spotubedl request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
}
if result.URL == "" {
return nil, fmt.Errorf("no download URL from spotubedl")
}
GoLog("[YouTube] Got download URL from SpotubeDL\n")
return &CobaltResponse{
Status: "tunnel",
URL: result.URL,
}, nil
}
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[YouTube] Download completed: %d bytes written\n", written)
return nil
}
func BuildYouTubeSearchURL(trackName, artistName string) string {
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
}
func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
func isYouTubeVideoID(s string) bool {
if len(s) != 11 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
func IsYouTubeURL(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "youtube.com") ||
strings.Contains(lower, "youtu.be") ||
strings.Contains(lower, "music.youtube.com")
}
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
func toYouTubeMusicURL(rawURL string) string {
videoID, err := ExtractYouTubeVideoID(rawURL)
if err != nil {
return rawURL
}
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func ExtractYouTubeVideoID(urlStr string) (string, error) {
if strings.Contains(urlStr, "youtu.be/") {
parts := strings.Split(urlStr, "youtu.be/")
if len(parts) >= 2 {
videoID := strings.Split(parts[1], "?")[0]
videoID = strings.Split(videoID, "&")[0]
return strings.TrimSpace(videoID), nil
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
// /watch?v=
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
// /embed/
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
// /v/
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
return "", fmt.Errorf("could not extract video ID from URL")
}
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader()
var quality YouTubeQuality
switch strings.ToLower(req.Quality) {
case "opus_256", "opus256", "opus":
quality = YouTubeQualityOpus256
case "mp3_320", "mp3320", "mp3":
quality = YouTubeQualityMP3320
default:
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
}
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
var youtubeURL string
var lookupErr error
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
}
// Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
}
}
// Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
}
}
// Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
youtubeURL = availability.YouTubeURL
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
} else if isrcErr != nil {
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
}
}
// Cobalt requires direct video URLs, not search URLs
if youtubeURL == "" {
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
}
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
if err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
var ext string
var format string
var bitrate int
switch quality {
case YouTubeQualityOpus256:
ext = ".opus"
format = "opus"
bitrate = 256
case YouTubeQualityMP3320:
ext = ".mp3"
format = "mp3"
bitrate = 320
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ext
var outputPath string
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
outputPath = req.OutputDir + "/" + filename
}
GoLog("[YouTube] Downloading to: %s\n", outputPath)
// Parallel fetch cover art + lyrics
var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
lyricsLRC := ""
var coverData []byte
if parallelResult != nil {
if parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
}
if parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
}
}
return YouTubeDownloadResult{
FilePath: outputPath,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Format: format,
Bitrate: bitrate,
LyricsLRC: lyricsLRC,
CoverData: coverData,
}, nil
}
+8
View File
@@ -217,6 +217,14 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "editFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let metadataJson = args["metadata_json"] as? String ?? "{}"
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
if let error = error { throw error }
return response
case "searchDeezerAll": case "searchDeezerAll":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.5.0'; static const String version = '3.6.0';
static const String buildNumber = '74'; static const String buildNumber = '77';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+378
View File
@@ -712,6 +712,12 @@ abstract class AppLocalizations {
/// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'** /// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'**
String get optionsSpotifyWarning; String get optionsSpotifyWarning;
/// Warning about Spotify API deprecation
///
/// In en, this message translates to:
/// **'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'**
String get optionsSpotifyDeprecationWarning;
/// Extensions page title /// Extensions page title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -988,6 +994,18 @@ abstract class AppLocalizations {
/// **'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'** /// **'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'**
String get aboutDabMusicDesc; String get aboutDabMusicDesc;
/// Name of SpotiSaver API service - DO NOT TRANSLATE
///
/// In en, this message translates to:
/// **'SpotiSaver'**
String get aboutSpotiSaver;
/// Credit for SpotiSaver API
///
/// In en, this message translates to:
/// **'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'**
String get aboutSpotiSaverDesc;
/// App description in header card /// App description in header card
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3484,6 +3502,12 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'** /// **'Actual quality depends on track availability from the service'**
String get qualityNote; String get qualityNote;
/// Note for YouTube service explaining lossy-only quality
///
/// In en, this message translates to:
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Setting - show quality picker /// Setting - show quality picker
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3508,6 +3532,24 @@ abstract class AppLocalizations {
/// **'Album Folder Structure'** /// **'Album Folder Structure'**
String get downloadAlbumFolderStructure; String get downloadAlbumFolderStructure;
/// Setting - choose whether artist folders use Album Artist or Track Artist
///
/// In en, this message translates to:
/// **'Use Album Artist for folders'**
String get downloadUseAlbumArtistForFolders;
/// Subtitle when Album Artist is used for folder naming
///
/// In en, this message translates to:
/// **'Artist folders use Album Artist when available'**
String get downloadUseAlbumArtistForFoldersAlbumSubtitle;
/// Subtitle when Track Artist is used for folder naming
///
/// In en, this message translates to:
/// **'Artist folders use Track Artist only'**
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
/// Setting - output file format /// Setting - output file format
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3922,6 +3964,18 @@ abstract class AppLocalizations {
/// **'Playlist'** /// **'Playlist'**
String get recentTypePlaylist; String get recentTypePlaylist;
/// Empty state text for recent access list
///
/// In en, this message translates to:
/// **'No recent items yet'**
String get recentEmpty;
/// Button label to unhide hidden downloads in recent access
///
/// In en, this message translates to:
/// **'Show All Downloads'**
String get recentShowAllDownloads;
/// Snackbar message when tapping playlist in recent access /// Snackbar message when tapping playlist in recent access
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4090,6 +4144,18 @@ abstract class AppLocalizations {
/// **'Scan music & detect duplicates'** /// **'Scan music & detect duplicates'**
String get settingsLocalLibrarySubtitle; String get settingsLocalLibrarySubtitle;
/// Settings menu item - cache management
///
/// In en, this message translates to:
/// **'Storage & Cache'**
String get settingsCache;
/// Subtitle for cache management menu
///
/// In en, this message translates to:
/// **'View size and clear cached data'**
String get settingsCacheSubtitle;
/// Library settings page title /// Library settings page title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4396,6 +4462,24 @@ abstract class AppLocalizations {
/// **'This Year'** /// **'This Year'**
String get libraryFilterDateYear; String get libraryFilterDateYear;
/// Filter section - sort order
///
/// In en, this message translates to:
/// **'Sort'**
String get libraryFilterSort;
/// Sort option - newest first
///
/// In en, this message translates to:
/// **'Latest'**
String get libraryFilterSortLatest;
/// Sort option - oldest first
///
/// In en, this message translates to:
/// **'Oldest'**
String get libraryFilterSortOldest;
/// Badge showing number of active filters /// Badge showing number of active filters
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4755,6 +4839,300 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'No orphaned entries found'** /// **'No orphaned entries found'**
String get cleanupOrphanedDownloadsNone; String get cleanupOrphanedDownloadsNone;
/// Cache management page title
///
/// In en, this message translates to:
/// **'Storage & Cache'**
String get cacheTitle;
/// Heading for cache summary card
///
/// In en, this message translates to:
/// **'Cache overview'**
String get cacheSummaryTitle;
/// Helper text for cache summary card
///
/// In en, this message translates to:
/// **'Clearing cache will not remove downloaded music files.'**
String get cacheSummarySubtitle;
/// Total cache size shown in summary
///
/// In en, this message translates to:
/// **'Estimated cache usage: {size}'**
String cacheEstimatedTotal(String size);
/// Section header for cache entries
///
/// In en, this message translates to:
/// **'Cached Data'**
String get cacheSectionStorage;
/// Section header for cleanup actions
///
/// In en, this message translates to:
/// **'Maintenance'**
String get cacheSectionMaintenance;
/// Cache item title for app cache directory
///
/// In en, this message translates to:
/// **'App cache directory'**
String get cacheAppDirectory;
/// Description of what app cache directory contains
///
/// In en, this message translates to:
/// **'HTTP responses, WebView data, and other temporary app data.'**
String get cacheAppDirectoryDesc;
/// Cache item title for temporary files directory
///
/// In en, this message translates to:
/// **'Temporary directory'**
String get cacheTempDirectory;
/// Description of what temporary directory contains
///
/// In en, this message translates to:
/// **'Temporary files from downloads and audio conversion.'**
String get cacheTempDirectoryDesc;
/// Cache item title for persistent cover images
///
/// In en, this message translates to:
/// **'Cover image cache'**
String get cacheCoverImage;
/// Description of what cover image cache contains
///
/// In en, this message translates to:
/// **'Downloaded album and track cover art. Will re-download when viewed.'**
String get cacheCoverImageDesc;
/// Cache item title for local library cover art images
///
/// In en, this message translates to:
/// **'Library cover cache'**
String get cacheLibraryCover;
/// Description of what library cover cache contains
///
/// In en, this message translates to:
/// **'Cover art extracted from local music files. Will re-extract on next scan.'**
String get cacheLibraryCoverDesc;
/// Cache item title for explore home feed cache
///
/// In en, this message translates to:
/// **'Explore feed cache'**
String get cacheExploreFeed;
/// Description of what explore feed cache contains
///
/// In en, this message translates to:
/// **'Explore tab content (new releases, trending). Will refresh on next visit.'**
String get cacheExploreFeedDesc;
/// Cache item title for track ID lookup cache
///
/// In en, this message translates to:
/// **'Track lookup cache'**
String get cacheTrackLookup;
/// Description of what track lookup cache contains
///
/// In en, this message translates to:
/// **'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'**
String get cacheTrackLookupDesc;
/// Description of what cleanup unused data does
///
/// In en, this message translates to:
/// **'Remove orphaned download history and library entries for missing files.'**
String get cacheCleanupUnusedDesc;
/// Label when cache category has no data
///
/// In en, this message translates to:
/// **'No cached data'**
String get cacheNoData;
/// Cache size and file count
///
/// In en, this message translates to:
/// **'{size} in {count} files'**
String cacheSizeWithFiles(String size, int count);
/// Cache size only
///
/// In en, this message translates to:
/// **'{size}'**
String cacheSizeOnly(String size);
/// Track cache entry count
///
/// In en, this message translates to:
/// **'{count} entries'**
String cacheEntries(int count);
/// Snackbar after clearing selected cache
///
/// In en, this message translates to:
/// **'Cleared: {target}'**
String cacheClearSuccess(String target);
/// Dialog title before clearing one cache category
///
/// In en, this message translates to:
/// **'Clear cache?'**
String get cacheClearConfirmTitle;
/// Dialog message before clearing selected cache
///
/// In en, this message translates to:
/// **'This will clear cached data for {target}. Downloaded music files will not be deleted.'**
String cacheClearConfirmMessage(String target);
/// Dialog title before clearing all caches
///
/// In en, this message translates to:
/// **'Clear all cache?'**
String get cacheClearAllConfirmTitle;
/// Dialog message before clearing all caches
///
/// In en, this message translates to:
/// **'This will clear all cache categories on this page. Downloaded music files will not be deleted.'**
String get cacheClearAllConfirmMessage;
/// Button label to clear all caches
///
/// In en, this message translates to:
/// **'Clear all cache'**
String get cacheClearAll;
/// Action title for cleaning unused entries
///
/// In en, this message translates to:
/// **'Cleanup unused data'**
String get cacheCleanupUnused;
/// Subtitle for cleanup unused data action
///
/// In en, this message translates to:
/// **'Remove orphaned download history and missing library entries'**
String get cacheCleanupUnusedSubtitle;
/// Snackbar after unused data cleanup
///
/// In en, this message translates to:
/// **'Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries'**
String cacheCleanupResult(int downloadCount, int libraryCount);
/// Button label to refresh cache statistics
///
/// In en, this message translates to:
/// **'Refresh stats'**
String get cacheRefreshStats;
/// Menu action - save album cover art as file
///
/// In en, this message translates to:
/// **'Save Cover Art'**
String get trackSaveCoverArt;
/// Subtitle for save cover art action
///
/// In en, this message translates to:
/// **'Save album art as .jpg file'**
String get trackSaveCoverArtSubtitle;
/// Menu action - save lyrics as .lrc file
///
/// In en, this message translates to:
/// **'Save Lyrics (.lrc)'**
String get trackSaveLyrics;
/// Subtitle for save lyrics action
///
/// In en, this message translates to:
/// **'Fetch and save lyrics as .lrc file'**
String get trackSaveLyricsSubtitle;
/// Menu action - re-embed metadata into audio file
///
/// In en, this message translates to:
/// **'Re-enrich Metadata'**
String get trackReEnrich;
/// Subtitle for re-enrich metadata action
///
/// In en, this message translates to:
/// **'Re-embed metadata without re-downloading'**
String get trackReEnrichSubtitle;
/// Subtitle for re-enrich metadata action for local items
///
/// In en, this message translates to:
/// **'Search metadata online and embed into file'**
String get trackReEnrichOnlineSubtitle;
/// Menu action - edit embedded metadata
///
/// In en, this message translates to:
/// **'Edit Metadata'**
String get trackEditMetadata;
/// Snackbar after cover art saved
///
/// In en, this message translates to:
/// **'Cover art saved to {fileName}'**
String trackCoverSaved(String fileName);
/// Snackbar when no cover art URL or embedded cover
///
/// In en, this message translates to:
/// **'No cover art source available'**
String get trackCoverNoSource;
/// Snackbar after lyrics saved
///
/// In en, this message translates to:
/// **'Lyrics saved to {fileName}'**
String trackLyricsSaved(String fileName);
/// Snackbar while re-enriching metadata
///
/// In en, this message translates to:
/// **'Re-enriching metadata...'**
String get trackReEnrichProgress;
/// Snackbar while searching metadata from internet for local items
///
/// In en, this message translates to:
/// **'Searching metadata online...'**
String get trackReEnrichSearching;
/// Snackbar after successful re-enrichment
///
/// In en, this message translates to:
/// **'Metadata re-enriched successfully'**
String get trackReEnrichSuccess;
/// Snackbar when FFmpeg embed fails for MP3/Opus
///
/// In en, this message translates to:
/// **'FFmpeg metadata embed failed'**
String get trackReEnrichFfmpegFailed;
/// Snackbar when save operation fails
///
/// In en, this message translates to:
/// **'Failed: {error}'**
String trackSaveFailed(String error);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+226
View File
@@ -352,6 +352,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com'; 'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Erweiterungen'; String get extensionsTitle => 'Erweiterungen';
@@ -504,6 +508,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!'; 'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
@@ -1922,6 +1933,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1934,6 +1949,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2169,6 +2195,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2275,6 +2307,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2442,6 +2480,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2678,4 +2725,183 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2663,4 +2710,183 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2663,6 +2710,185 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2663,4 +2710,183 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2663,4 +2710,183 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
File diff suppressed because it is too large Load Diff
+226
View File
@@ -340,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。'; 'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => '拡張'; String get extensionsTitle => '拡張';
@@ -487,6 +491,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
@@ -1895,6 +1906,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1907,6 +1922,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造'; String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => '形式を保存'; String get downloadSaveFormat => '形式を保存';
@@ -2140,6 +2166,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'プレイリスト'; String get recentTypePlaylist => 'プレイリスト';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'プレイリスト: $name'; return 'プレイリスト: $name';
@@ -2246,6 +2278,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2413,6 +2451,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2649,4 +2696,183 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2663,4 +2710,183 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2663,4 +2710,183 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2663,6 +2710,185 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).
+226
View File
@@ -354,6 +354,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com'; 'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Расширения'; String get extensionsTitle => 'Расширения';
@@ -504,6 +508,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'Лучший API для стриминга Qobuz. Без него загрузка файлов в высоком разрешении была бы невозможна!'; 'Лучший API для стриминга Qobuz. Без него загрузка файлов в высоком разрешении была бы невозможна!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
@@ -1945,6 +1956,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе'; 'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1957,6 +1972,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Структура папок альбома'; String get downloadAlbumFolderStructure => 'Структура папок альбома';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Формат сохранения'; String get downloadSaveFormat => 'Формат сохранения';
@@ -2199,6 +2225,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Плейлист'; String get recentTypePlaylist => 'Плейлист';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Плейлист: $name'; return 'Плейлист: $name';
@@ -2306,6 +2338,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2473,6 +2511,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2709,4 +2756,183 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -348,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin'; 'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Eklentiler'; String get extensionsTitle => 'Eklentiler';
@@ -498,6 +502,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!'; 'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
@@ -1922,6 +1933,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1934,6 +1949,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2169,6 +2195,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2275,6 +2307,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2442,6 +2480,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2678,4 +2725,183 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+226
View File
@@ -343,6 +343,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; 'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get aboutDabMusicDesc => String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'; 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override @override
String get aboutAppDescription => String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get libraryFilterDateYear => 'This Year'; String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override @override
String libraryFilterActive(int count) { String libraryFilterActive(int count) {
return '$count filter(s) active'; return '$count filter(s) active';
@@ -2663,6 +2710,185 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Chinese, as used in China (`zh_CN`). /// The translations for Chinese, as used in China (`zh_CN`).
+183 -1
View File
@@ -241,6 +241,8 @@
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"}, "@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"}, "@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
"@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"},
"extensionsTitle": "Extensions", "extensionsTitle": "Extensions",
"@extensionsTitle": {"description": "Extensions page title"}, "@extensionsTitle": {"description": "Extensions page title"},
@@ -346,6 +348,10 @@
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"}, "@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!", "aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"}, "@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
"aboutSpotiSaver": "SpotiSaver",
"@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"},
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
"@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"@aboutAppDescription": {"description": "App description in header card"}, "@aboutAppDescription": {"description": "App description in header card"},
@@ -1408,6 +1414,8 @@
"@lossyFormatOpusSubtitle": {"description": "Opus format description"}, "@lossyFormatOpusSubtitle": {"description": "Opus format description"},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"}, "@qualityNote": {"description": "Note about quality availability"},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"}, "@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
@@ -1417,6 +1425,12 @@
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"}, "@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
"downloadAlbumFolderStructure": "Album Folder Structure", "downloadAlbumFolderStructure": "Album Folder Structure",
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"}, "@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
"downloadSaveFormat": "Save Format", "downloadSaveFormat": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"}, "@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service", "downloadSelectService": "Select Service",
@@ -1590,6 +1604,12 @@
"@recentTypeSong": {"description": "Recent access item type - song/track"}, "@recentTypeSong": {"description": "Recent access item type - song/track"},
"recentTypePlaylist": "Playlist", "recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {"description": "Recent access item type - playlist"}, "@recentTypePlaylist": {"description": "Recent access item type - playlist"},
"recentEmpty": "No recent items yet",
"@recentEmpty": {"description": "Empty state text for recent access list"},
"recentShowAllDownloads": "Show All Downloads",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}", "recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": { "@recentPlaylistInfo": {
@@ -1700,6 +1720,10 @@
"@settingsLocalLibrary": {"description": "Settings menu item - local library"}, "@settingsLocalLibrary": {"description": "Settings menu item - local library"},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates", "settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"}, "@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
"settingsCache": "Storage & Cache",
"@settingsCache": {"description": "Settings menu item - cache management"},
"settingsCacheSubtitle": "View size and clear cached data",
"@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"},
"libraryTitle": "Local Library", "libraryTitle": "Local Library",
"@libraryTitle": {"description": "Library settings page title"}, "@libraryTitle": {"description": "Library settings page title"},
"libraryStatus": "Library Status", "libraryStatus": "Library Status",
@@ -1824,6 +1848,12 @@
"@libraryFilterDateMonth": {"description": "Filter option - this month"}, "@libraryFilterDateMonth": {"description": "Filter option - this month"},
"libraryFilterDateYear": "This Year", "libraryFilterDateYear": "This Year",
"@libraryFilterDateYear": {"description": "Filter option - this year"}, "@libraryFilterDateYear": {"description": "Filter option - this year"},
"libraryFilterSort": "Sort",
"@libraryFilterSort": {"description": "Filter section - sort order"},
"libraryFilterSortLatest": "Latest",
"@libraryFilterSortLatest": {"description": "Sort option - newest first"},
"libraryFilterSortOldest": "Oldest",
"@libraryFilterSortOldest": {"description": "Sort option - oldest first"},
"libraryFilterActive": "{count} filter(s) active", "libraryFilterActive": "{count} filter(s) active",
"@libraryFilterActive": { "@libraryFilterActive": {
"description": "Badge showing number of active filters", "description": "Badge showing number of active filters",
@@ -2000,5 +2030,157 @@
} }
}, },
"cleanupOrphanedDownloadsNone": "No orphaned entries found", "cleanupOrphanedDownloadsNone": "No orphaned entries found",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"} "@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
"cacheTitle": "Storage & Cache",
"@cacheTitle": {"description": "Cache management page title"},
"cacheSummaryTitle": "Cache overview",
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
"cacheEstimatedTotal": "Estimated cache usage: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheSectionStorage": "Cached Data",
"@cacheSectionStorage": {"description": "Section header for cache entries"},
"cacheSectionMaintenance": "Maintenance",
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
"cacheAppDirectory": "App cache directory",
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
"cacheTempDirectory": "Temporary directory",
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
"cacheCoverImage": "Cover image cache",
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
"cacheLibraryCover": "Library cover cache",
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
"cacheExploreFeed": "Explore feed cache",
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
"cacheTrackLookup": "Track lookup cache",
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
"cacheNoData": "No cached data",
"@cacheNoData": {"description": "Label when cache category has no data"},
"cacheSizeWithFiles": "{size} in {count} files",
"@cacheSizeWithFiles": {
"description": "Cache size and file count",
"placeholders": {
"size": {"type": "String"},
"count": {"type": "int"}
}
},
"cacheSizeOnly": "{size}",
"@cacheSizeOnly": {
"description": "Cache size only",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheEntries": "{count} entries",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
"count": {"type": "int"}
}
},
"cacheClearSuccess": "Cleared: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearConfirmTitle": "Clear cache?",
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearAllConfirmTitle": "Clear all cache?",
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
"cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.",
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
"cacheClearAll": "Clear all cache",
"@cacheClearAll": {"description": "Button label to clear all caches"},
"cacheCleanupUnused": "Cleanup unused data",
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
"downloadCount": {"type": "int"},
"libraryCount": {"type": "int"}
}
},
"cacheRefreshStats": "Refresh stats",
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
"trackSaveCoverArt": "Save Cover Art",
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
"trackSaveCoverArtSubtitle": "Save album art as .jpg file",
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
"trackSaveLyrics": "Save Lyrics (.lrc)",
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
"trackReEnrich": "Re-enrich Metadata",
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
"trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
"trackCoverSaved": "Cover art saved to {fileName}",
"@trackCoverSaved": {
"description": "Snackbar after cover art saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackCoverNoSource": "No cover art source available",
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
"trackLyricsSaved": "Lyrics saved to {fileName}",
"@trackLyricsSaved": {
"description": "Snackbar after lyrics saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackReEnrichProgress": "Re-enriching metadata...",
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
"trackReEnrichSearching": "Searching metadata online...",
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
"trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed",
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"placeholders": {
"error": {"type": "String"}
}
}
} }
+347 -163
View File
@@ -151,6 +151,14 @@
"@settingsExtensions": { "@settingsExtensions": {
"description": "Settings section - extension management" "description": "Settings section - extension management"
}, },
"settingsCache": "Penyimpanan & Cache",
"@settingsCache": {
"description": "Settings menu item - cache management"
},
"settingsCacheSubtitle": "Lihat ukuran dan bersihkan data cache",
"@settingsCacheSubtitle": {
"description": "Subtitle for cache management menu"
},
"settingsAbout": "Tentang", "settingsAbout": "Tentang",
"@settingsAbout": { "@settingsAbout": {
"description": "Settings section - app info" "description": "Settings section - app info"
@@ -426,6 +434,10 @@
"@optionsSpotifyWarning": { "@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement" "description": "Info about Spotify API requirement"
}, },
"optionsSpotifyDeprecationWarning": "Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.",
"@optionsSpotifyDeprecationWarning": {
"description": "Warning about Spotify API deprecation"
},
"extensionsTitle": "Ekstensi", "extensionsTitle": "Ekstensi",
"@extensionsTitle": { "@extensionsTitle": {
"description": "Extensions page title" "description": "Extensions page title"
@@ -2465,6 +2477,18 @@
"@downloadAlbumFolderStructure": { "@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization" "description": "Setting - album folder organization"
}, },
"downloadUseAlbumArtistForFolders": "Gunakan Album Artist untuk folder",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Folder artis memakai Album Artist jika tersedia",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
"description": "Subtitle when Album Artist is used for folder naming"
},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Folder artis hanya memakai Track Artist",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
},
"downloadSaveFormat": "Simpan Format", "downloadSaveFormat": "Simpan Format",
"@downloadSaveFormat": { "@downloadSaveFormat": {
"description": "Setting - output file format" "description": "Setting - output file format"
@@ -2727,6 +2751,14 @@
"@recentTypePlaylist": { "@recentTypePlaylist": {
"description": "Recent access item type - playlist" "description": "Recent access item type - playlist"
}, },
"recentEmpty": "Belum ada item terbaru",
"@recentEmpty": {
"description": "Empty state text for recent access list"
},
"recentShowAllDownloads": "Tampilkan Semua Download",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}", "recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": { "@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access", "description": "Snackbar message when tapping playlist in recent access",
@@ -2857,166 +2889,318 @@
} }
} }
}, },
"discographyNoAlbums": "No albums available", "discographyNoAlbums": "No albums available",
"@discographyNoAlbums": { "@discographyNoAlbums": {
"description": "Error - no albums found for artist" "description": "Error - no albums found for artist"
}, },
"discographyFailedToFetch": "Failed to fetch some albums", "discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": { "@discographyFailedToFetch": {
"description": "Error - some albums failed to load" "description": "Error - some albums failed to load"
}, },
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!", "tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
"@tutorialWelcomeTitle": { "@tutorialWelcomeTitle": {
"description": "Tutorial welcome page title" "description": "Tutorial welcome page title"
}, },
"tutorialWelcomeDesc": "Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.", "tutorialWelcomeDesc": "Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
"@tutorialWelcomeDesc": { "@tutorialWelcomeDesc": {
"description": "Tutorial welcome page description" "description": "Tutorial welcome page description"
}, },
"tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung", "tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
"@tutorialWelcomeTip1": { "@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1" "description": "Tutorial welcome tip 1"
}, },
"tutorialWelcomeTip2": "Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music", "tutorialWelcomeTip2": "Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music",
"@tutorialWelcomeTip2": { "@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2" "description": "Tutorial welcome tip 2"
}, },
"tutorialWelcomeTip3": "Metadata, cover art, dan lirik otomatis tertanam", "tutorialWelcomeTip3": "Metadata, cover art, dan lirik otomatis tertanam",
"@tutorialWelcomeTip3": { "@tutorialWelcomeTip3": {
"description": "Tutorial welcome tip 3" "description": "Tutorial welcome tip 3"
}, },
"tutorialSearchTitle": "Mencari Musik", "tutorialSearchTitle": "Mencari Musik",
"@tutorialSearchTitle": { "@tutorialSearchTitle": {
"description": "Tutorial search page title" "description": "Tutorial search page title"
}, },
"tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.", "tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
"@tutorialSearchDesc": { "@tutorialSearchDesc": {
"description": "Tutorial search page description" "description": "Tutorial search page description"
}, },
"tutorialSearchTip1": "Tempel URL Spotify atau Deezer langsung di kotak pencarian", "tutorialSearchTip1": "Tempel URL Spotify atau Deezer langsung di kotak pencarian",
"@tutorialSearchTip1": { "@tutorialSearchTip1": {
"description": "Tutorial search tip 1" "description": "Tutorial search tip 1"
}, },
"tutorialSearchTip2": "Atau ketik nama lagu, artis, atau album untuk mencari", "tutorialSearchTip2": "Atau ketik nama lagu, artis, atau album untuk mencari",
"@tutorialSearchTip2": { "@tutorialSearchTip2": {
"description": "Tutorial search tip 2" "description": "Tutorial search tip 2"
}, },
"tutorialSearchTip3": "Mendukung lagu, album, playlist, dan halaman artis", "tutorialSearchTip3": "Mendukung lagu, album, playlist, dan halaman artis",
"@tutorialSearchTip3": { "@tutorialSearchTip3": {
"description": "Tutorial search tip 3" "description": "Tutorial search tip 3"
}, },
"tutorialDownloadTitle": "Mengunduh Musik", "tutorialDownloadTitle": "Mengunduh Musik",
"@tutorialDownloadTitle": { "@tutorialDownloadTitle": {
"description": "Tutorial download page title" "description": "Tutorial download page title"
}, },
"tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini caranya.", "tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini caranya.",
"@tutorialDownloadDesc": { "@tutorialDownloadDesc": {
"description": "Tutorial download page description" "description": "Tutorial download page description"
}, },
"tutorialDownloadTip1": "Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh", "tutorialDownloadTip1": "Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh",
"@tutorialDownloadTip1": { "@tutorialDownloadTip1": {
"description": "Tutorial download tip 1" "description": "Tutorial download tip 1"
}, },
"tutorialDownloadTip2": "Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)", "tutorialDownloadTip2": "Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)",
"@tutorialDownloadTip2": { "@tutorialDownloadTip2": {
"description": "Tutorial download tip 2" "description": "Tutorial download tip 2"
}, },
"tutorialDownloadTip3": "Unduh seluruh album atau playlist dengan satu ketukan", "tutorialDownloadTip3": "Unduh seluruh album atau playlist dengan satu ketukan",
"@tutorialDownloadTip3": { "@tutorialDownloadTip3": {
"description": "Tutorial download tip 3" "description": "Tutorial download tip 3"
}, },
"tutorialLibraryTitle": "Perpustakaan Anda", "tutorialLibraryTitle": "Perpustakaan Anda",
"@tutorialLibraryTitle": { "@tutorialLibraryTitle": {
"description": "Tutorial library page title" "description": "Tutorial library page title"
}, },
"tutorialLibraryDesc": "Semua musik yang Anda unduh terorganisir di tab Perpustakaan.", "tutorialLibraryDesc": "Semua musik yang Anda unduh terorganisir di tab Perpustakaan.",
"@tutorialLibraryDesc": { "@tutorialLibraryDesc": {
"description": "Tutorial library page description" "description": "Tutorial library page description"
}, },
"tutorialLibraryTip1": "Lihat progres unduhan dan antrian di tab Perpustakaan", "tutorialLibraryTip1": "Lihat progres unduhan dan antrian di tab Perpustakaan",
"@tutorialLibraryTip1": { "@tutorialLibraryTip1": {
"description": "Tutorial library tip 1" "description": "Tutorial library tip 1"
}, },
"tutorialLibraryTip2": "Ketuk lagu mana pun untuk memutarnya dengan pemutar musik", "tutorialLibraryTip2": "Ketuk lagu mana pun untuk memutarnya dengan pemutar musik",
"@tutorialLibraryTip2": { "@tutorialLibraryTip2": {
"description": "Tutorial library tip 2" "description": "Tutorial library tip 2"
}, },
"tutorialLibraryTip3": "Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik", "tutorialLibraryTip3": "Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik",
"@tutorialLibraryTip3": { "@tutorialLibraryTip3": {
"description": "Tutorial library tip 3" "description": "Tutorial library tip 3"
}, },
"tutorialExtensionsTitle": "Ekstensi", "tutorialExtensionsTitle": "Ekstensi",
"@tutorialExtensionsTitle": { "@tutorialExtensionsTitle": {
"description": "Tutorial extensions page title" "description": "Tutorial extensions page title"
}, },
"tutorialExtensionsDesc": "Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.", "tutorialExtensionsDesc": "Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.",
"@tutorialExtensionsDesc": { "@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description" "description": "Tutorial extensions page description"
}, },
"tutorialExtensionsTip1": "Jelajahi tab Toko untuk menemukan ekstensi berguna", "tutorialExtensionsTip1": "Jelajahi tab Toko untuk menemukan ekstensi berguna",
"@tutorialExtensionsTip1": { "@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1" "description": "Tutorial extensions tip 1"
}, },
"tutorialExtensionsTip2": "Tambahkan provider unduhan atau sumber pencarian baru", "tutorialExtensionsTip2": "Tambahkan provider unduhan atau sumber pencarian baru",
"@tutorialExtensionsTip2": { "@tutorialExtensionsTip2": {
"description": "Tutorial extensions tip 2" "description": "Tutorial extensions tip 2"
}, },
"tutorialExtensionsTip3": "Dapatkan lirik, metadata lebih baik, dan fitur lainnya", "tutorialExtensionsTip3": "Dapatkan lirik, metadata lebih baik, dan fitur lainnya",
"@tutorialExtensionsTip3": { "@tutorialExtensionsTip3": {
"description": "Tutorial extensions tip 3" "description": "Tutorial extensions tip 3"
}, },
"tutorialSettingsTitle": "Sesuaikan Pengalaman Anda", "tutorialSettingsTitle": "Sesuaikan Pengalaman Anda",
"@tutorialSettingsTitle": { "@tutorialSettingsTitle": {
"description": "Tutorial settings page title" "description": "Tutorial settings page title"
}, },
"tutorialSettingsDesc": "Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.", "tutorialSettingsDesc": "Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.",
"@tutorialSettingsDesc": { "@tutorialSettingsDesc": {
"description": "Tutorial settings page description" "description": "Tutorial settings page description"
}, },
"tutorialSettingsTip1": "Ubah lokasi unduhan dan organisasi folder", "tutorialSettingsTip1": "Ubah lokasi unduhan dan organisasi folder",
"@tutorialSettingsTip1": { "@tutorialSettingsTip1": {
"description": "Tutorial settings tip 1" "description": "Tutorial settings tip 1"
}, },
"tutorialSettingsTip2": "Atur kualitas audio dan preferensi format default", "tutorialSettingsTip2": "Atur kualitas audio dan preferensi format default",
"@tutorialSettingsTip2": { "@tutorialSettingsTip2": {
"description": "Tutorial settings tip 2" "description": "Tutorial settings tip 2"
}, },
"tutorialSettingsTip3": "Sesuaikan tema dan tampilan aplikasi", "tutorialSettingsTip3": "Sesuaikan tema dan tampilan aplikasi",
"@tutorialSettingsTip3": { "@tutorialSettingsTip3": {
"description": "Tutorial settings tip 3" "description": "Tutorial settings tip 3"
}, },
"tutorialReadyMessage": "Anda siap! Mulai unduh musik favorit Anda sekarang.", "tutorialReadyMessage": "Anda siap! Mulai unduh musik favorit Anda sekarang.",
"@tutorialReadyMessage": { "@tutorialReadyMessage": {
"description": "Tutorial completion message" "description": "Tutorial completion message"
}, },
"tutorialExample": "CONTOH", "tutorialExample": "CONTOH",
"@tutorialExample": { "@tutorialExample": {
"description": "Example label in tutorial" "description": "Example label in tutorial"
}, },
"libraryForceFullScan": "Pindai Ulang Penuh", "libraryForceFullScan": "Pindai Ulang Penuh",
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"}, "@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
"libraryForceFullScanSubtitle": "Pindai ulang semua file, abaikan cache", "libraryForceFullScanSubtitle": "Pindai ulang semua file, abaikan cache",
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"}, "@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
"cleanupOrphanedDownloads": "Bersihkan Entri Unduhan Tidak Valid", "cleanupOrphanedDownloads": "Bersihkan Entri Unduhan Tidak Valid",
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"}, "@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
"cleanupOrphanedDownloadsSubtitle": "Hapus entri riwayat untuk file yang tidak ada lagi", "cleanupOrphanedDownloadsSubtitle": "Hapus entri riwayat untuk file yang tidak ada lagi",
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"}, "@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
"cleanupOrphanedDownloadsResult": "Menghapus {count} entri unduhan tidak valid dari riwayat", "cleanupOrphanedDownloadsResult": "Menghapus {count} entri unduhan tidak valid dari riwayat",
"@cleanupOrphanedDownloadsResult": { "@cleanupOrphanedDownloadsResult": {
"description": "Snackbar message after orphan cleanup", "description": "Snackbar message after orphan cleanup",
"placeholders": { "placeholders": {
"count": {"type": "int"} "count": {"type": "int"}
} }
}, },
"cleanupOrphanedDownloadsNone": "Tidak ada entri unduhan tidak valid", "cleanupOrphanedDownloadsNone": "Tidak ada entri unduhan tidak valid",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"} "@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
}
"cacheTitle": "Penyimpanan & Cache",
"@cacheTitle": {"description": "Cache management page title"},
"cacheSummaryTitle": "Ringkasan cache",
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
"cacheSummarySubtitle": "Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.",
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
"cacheEstimatedTotal": "Estimasi penggunaan cache: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheSectionStorage": "Data Cache",
"@cacheSectionStorage": {"description": "Section header for cache entries"},
"cacheSectionMaintenance": "Perawatan",
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
"cacheAppDirectory": "Direktori cache aplikasi",
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
"cacheAppDirectoryDesc": "Respons HTTP, data WebView, dan data sementara aplikasi.",
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
"cacheTempDirectory": "Direktori sementara",
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
"cacheTempDirectoryDesc": "File sementara dari proses download dan konversi audio.",
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
"cacheCoverImage": "Cache gambar cover",
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
"cacheCoverImageDesc": "Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.",
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
"cacheLibraryCover": "Cache cover library",
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
"cacheLibraryCoverDesc": "Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.",
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
"cacheExploreFeed": "Cache feed Explore",
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
"cacheExploreFeedDesc": "Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.",
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
"cacheTrackLookup": "Cache pencocokan lagu",
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
"cacheTrackLookupDesc": "Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.",
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
"cacheCleanupUnusedDesc": "Hapus entri riwayat download dan library yang filenya sudah tidak ada.",
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
"cacheNoData": "Tidak ada data cache",
"@cacheNoData": {"description": "Label when cache category has no data"},
"cacheSizeWithFiles": "{size} dalam {count} file",
"@cacheSizeWithFiles": {
"description": "Cache size and file count",
"placeholders": {
"size": {"type": "String"},
"count": {"type": "int"}
}
},
"cacheSizeOnly": "{size}",
"@cacheSizeOnly": {
"description": "Cache size only",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheEntries": "{count} entri",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
"count": {"type": "int"}
}
},
"cacheClearSuccess": "Berhasil dibersihkan: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearConfirmTitle": "Bersihkan cache?",
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
"cacheClearConfirmMessage": "Ini akan membersihkan data cache untuk {target}. File musik yang sudah diunduh tidak akan dihapus.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearAllConfirmTitle": "Bersihkan semua cache?",
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
"cacheClearAllConfirmMessage": "Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.",
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
"cacheClearAll": "Bersihkan semua cache",
"@cacheClearAll": {"description": "Button label to clear all caches"},
"cacheCleanupUnused": "Bersihkan data tidak terpakai",
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
"cacheCleanupUnusedSubtitle": "Hapus riwayat unduhan yatim dan entri library yang file-nya hilang",
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
"cacheCleanupResult": "Pembersihan selesai: {downloadCount} unduhan yatim, {libraryCount} entri library hilang",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
"downloadCount": {"type": "int"},
"libraryCount": {"type": "int"}
}
},
"cacheRefreshStats": "Segarkan statistik",
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"},
"trackSaveCoverArt": "Simpan Cover Art",
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
"trackSaveCoverArtSubtitle": "Simpan cover album sebagai file .jpg",
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
"trackSaveLyrics": "Simpan Lirik (.lrc)",
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
"trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc",
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
"trackReEnrich": "Perkaya Ulang Metadata",
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
"trackReEnrichSubtitle": "Tanamkan ulang metadata tanpa mengunduh ulang",
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
"trackReEnrichOnlineSubtitle": "Cari metadata dari internet dan tanamkan ke file",
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
"trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
"trackCoverSaved": "Cover art disimpan ke {fileName}",
"@trackCoverSaved": {
"description": "Snackbar after cover art saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackCoverNoSource": "Tidak ada sumber cover art",
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
"trackLyricsSaved": "Lirik disimpan ke {fileName}",
"@trackLyricsSaved": {
"description": "Snackbar after lyrics saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackReEnrichProgress": "Memperkaya ulang metadata...",
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
"trackReEnrichSearching": "Mencari metadata dari internet...",
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
"trackReEnrichSuccess": "Metadata berhasil diperkaya ulang",
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
"trackReEnrichFfmpegFailed": "Gagal menanamkan metadata via FFmpeg",
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
"trackSaveFailed": "Gagal: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"placeholders": {
"error": {"type": "String"}
}
}
}
+22 -18
View File
@@ -11,21 +11,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp( runApp(
ProviderScope( ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
child: const _EagerInitialization(
child: SpotiFLACApp(),
),
),
); );
} }
@@ -35,27 +23,43 @@ class _EagerInitialization extends ConsumerStatefulWidget {
final Widget child; final Widget child;
@override @override
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState(); ConsumerState<_EagerInitialization> createState() =>
_EagerInitializationState();
} }
class _EagerInitializationState extends ConsumerState<_EagerInitialization> { class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_initializeAppServices();
_initializeExtensions(); _initializeExtensions();
ref.read(downloadHistoryProvider); ref.read(downloadHistoryProvider);
} }
Future<void> _initializeAppServices() async {
try {
await CoverCacheManager.initialize();
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
} catch (e) {
debugPrint('Failed to initialize app services: $e');
}
}
Future<void> _initializeExtensions() async { Future<void> _initializeExtensions() async {
try { try {
final appDir = await getApplicationDocumentsDirectory(); final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions'; final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data'; final dataDir = '${appDir.path}/extension_data';
await Directory(extensionsDir).create(recursive: true); await Directory(extensionsDir).create(recursive: true);
await Directory(dataDir).create(recursive: true); await Directory(dataDir).create(recursive: true);
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir); await ref
.read(extensionProvider.notifier)
.initialize(extensionsDir, dataDir);
} catch (e) { } catch (e) {
debugPrint('Failed to initialize extensions: $e'); debugPrint('Failed to initialize extensions: $e');
} }
+4
View File
@@ -28,6 +28,7 @@ class DownloadItem {
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double speedMBps; final double speedMBps;
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
final String? filePath; final String? filePath;
final String? error; final String? error;
final DownloadErrorType? errorType; final DownloadErrorType? errorType;
@@ -41,6 +42,7 @@ class DownloadItem {
this.status = DownloadStatus.queued, this.status = DownloadStatus.queued,
this.progress = 0.0, this.progress = 0.0,
this.speedMBps = 0.0, this.speedMBps = 0.0,
this.bytesReceived = 0,
this.filePath, this.filePath,
this.error, this.error,
this.errorType, this.errorType,
@@ -55,6 +57,7 @@ class DownloadItem {
DownloadStatus? status, DownloadStatus? status,
double? progress, double? progress,
double? speedMBps, double? speedMBps,
int? bytesReceived,
String? filePath, String? filePath,
String? error, String? error,
DownloadErrorType? errorType, DownloadErrorType? errorType,
@@ -68,6 +71,7 @@ class DownloadItem {
status: status ?? this.status, status: status ?? this.status,
progress: progress ?? this.progress, progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps, speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived,
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
error: error ?? this.error, error: error ?? this.error,
errorType: errorType ?? this.errorType, errorType: errorType ?? this.errorType,
+2
View File
@@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
DownloadStatus.queued, DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0, progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0, speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?, filePath: json['filePath'] as String?,
error: json['error'] as String?, error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -30,6 +31,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'status': _$DownloadStatusEnumMap[instance.status]!, 'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress, 'progress': instance.progress,
'speedMBps': instance.speedMBps, 'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived,
'filePath': instance.filePath, 'filePath': instance.filePath,
'error': instance.error, 'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+5
View File
@@ -19,6 +19,7 @@ class AppSettings {
final String updateChannel; final String updateChannel;
final bool hasSearchedBefore; final bool hasSearchedBefore;
final String folderOrganization; final String folderOrganization;
final bool useAlbumArtistForFolders;
final String historyViewMode; final String historyViewMode;
final String historyFilterMode; final String historyFilterMode;
final bool askQualityBeforeDownload; final bool askQualityBeforeDownload;
@@ -63,6 +64,7 @@ class AppSettings {
this.updateChannel = 'stable', this.updateChannel = 'stable',
this.hasSearchedBefore = false, this.hasSearchedBefore = false,
this.folderOrganization = 'none', this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.historyViewMode = 'grid', this.historyViewMode = 'grid',
this.historyFilterMode = 'all', this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true, this.askQualityBeforeDownload = true,
@@ -106,6 +108,7 @@ class AppSettings {
String? updateChannel, String? updateChannel,
bool? hasSearchedBefore, bool? hasSearchedBefore,
String? folderOrganization, String? folderOrganization,
bool? useAlbumArtistForFolders,
String? historyViewMode, String? historyViewMode,
String? historyFilterMode, String? historyFilterMode,
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
@@ -149,6 +152,8 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel, updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization, folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
historyViewMode: historyViewMode ?? this.historyViewMode, historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode, historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
+2
View File
@@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable', updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none', folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all', historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -68,6 +69,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'updateChannel': instance.updateChannel, 'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore, 'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization, 'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'historyViewMode': instance.historyViewMode, 'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode, 'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
+477 -121
View File
@@ -226,8 +226,11 @@ class DownloadHistoryState {
} }
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> { class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
static const int _safRepairBatchSize = 20;
static const int _safRepairMaxPerLaunch = 60;
final HistoryDatabase _db = HistoryDatabase.instance; final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false; bool _isLoaded = false;
bool _isSafRepairInProgress = false;
@override @override
DownloadHistoryState build() { DownloadHistoryState build() {
@@ -267,8 +270,14 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (Platform.isAndroid) { if (Platform.isAndroid) {
Future.microtask(() async { Future.microtask(() async {
await _repairMissingSafEntries(items); await _repairMissingSafEntries(
items,
maxItems: _safRepairMaxPerLaunch,
);
await cleanupOrphanedDownloads();
}); });
} else {
Future.microtask(() => cleanupOrphanedDownloads());
} }
} catch (e, stack) { } catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack); _historyLog.e('Failed to load history from database: $e', e, stack);
@@ -285,10 +294,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return ''; return '';
} }
Future<void> _repairMissingSafEntries(List<DownloadHistoryItem> items) async { Future<void> _repairMissingSafEntries(
final updatedItems = [...items]; List<DownloadHistoryItem> items, {
var changed = false; required int maxItems,
}) async {
if (_isSafRepairInProgress || items.isEmpty) {
return;
}
_isSafRepairInProgress = true;
final candidateIndexes = <int>[];
for (var i = 0; i < items.length; i++) { for (var i = 0; i < items.length; i++) {
final item = items[i]; final item = items[i];
if (item.storageMode != 'saf') continue; if (item.storageMode != 'saf') continue;
@@ -299,46 +314,85 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
if (item.filePath.isEmpty || !isContentUri(item.filePath)) { if (item.filePath.isEmpty || !isContentUri(item.filePath)) {
continue; continue;
} }
candidateIndexes.add(i);
final exists = await fileExists(item.filePath); if (candidateIndexes.length >= maxItems) break;
if (exists) continue;
final fallbackName = item.safFileName ?? _fileNameFromUri(item.filePath);
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue;
}
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = resolved['uri'] as String? ?? '';
if (newUri.isEmpty) continue;
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir: (newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
updatedItems[i] = updated;
changed = true;
await _db.upsert(updated.toJson());
_historyLog.i('Repaired SAF URI for history item: ${item.id}');
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
} }
if (changed) { if (candidateIndexes.isEmpty) {
state = state.copyWith(items: updatedItems); _isSafRepairInProgress = false;
return;
}
final updatedItems = [...items];
var changed = false;
var repairedCount = 0;
var verifiedCount = 0;
try {
for (var c = 0; c < candidateIndexes.length; c++) {
final i = candidateIndexes[c];
final item = items[i];
final exists = await fileExists(item.filePath);
if (exists) {
final verified = item.copyWith(
safRepaired: true,
safFileName: item.safFileName ?? _fileNameFromUri(item.filePath),
);
updatedItems[i] = verified;
changed = true;
verifiedCount++;
await _db.upsert(verified.toJson());
} else {
final fallbackName =
item.safFileName ?? _fileNameFromUri(item.filePath);
if (fallbackName.isEmpty) {
_historyLog.w('Missing SAF filename for history item: ${item.id}');
continue;
}
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: item.downloadTreeUri!,
relativeDir: item.safRelativeDir ?? '',
fileName: fallbackName,
);
final newUri = resolved['uri'] as String? ?? '';
if (newUri.isEmpty) continue;
final newRelativeDir = resolved['relative_dir'] as String?;
final updated = item.copyWith(
filePath: newUri,
safRelativeDir:
(newRelativeDir != null && newRelativeDir.isNotEmpty)
? newRelativeDir
: item.safRelativeDir,
safFileName: fallbackName,
safRepaired: true,
);
updatedItems[i] = updated;
changed = true;
repairedCount++;
await _db.upsert(updated.toJson());
} catch (e) {
_historyLog.w('Failed to repair SAF URI: $e');
}
}
if ((c + 1) % _safRepairBatchSize == 0) {
await Future.delayed(const Duration(milliseconds: 16));
}
}
if (changed) {
state = state.copyWith(items: updatedItems);
_historyLog.i(
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${candidateIndexes.length}',
);
}
} finally {
_isSafRepairInProgress = false;
} }
} }
@@ -412,18 +466,18 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
/// Returns the number of orphaned entries removed /// Returns the number of orphaned entries removed
Future<int> cleanupOrphanedDownloads() async { Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...'); _historyLog.i('Starting orphaned downloads cleanup...');
final entries = await _db.getAllEntriesWithPaths(); final entries = await _db.getAllEntriesWithPaths();
final orphanedIds = <String>[]; final orphanedIds = <String>[];
for (final entry in entries) { for (final entry in entries) {
final id = entry['id'] as String; final id = entry['id'] as String;
final filePath = entry['file_path'] as String?; final filePath = entry['file_path'] as String?;
if (filePath == null || filePath.isEmpty) continue; if (filePath == null || filePath.isEmpty) continue;
bool exists = false; bool exists = false;
if (filePath.startsWith('content://')) { if (filePath.startsWith('content://')) {
// SAF path - check via platform bridge // SAF path - check via platform bridge
try { try {
@@ -436,31 +490,33 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
// Regular file path // Regular file path
exists = File(filePath).existsSync(); exists = File(filePath).existsSync();
} }
if (!exists) { if (!exists) {
orphanedIds.add(id); orphanedIds.add(id);
_historyLog.d('Found orphaned entry: $id ($filePath)'); _historyLog.d('Found orphaned entry: $id ($filePath)');
} }
} }
if (orphanedIds.isEmpty) { if (orphanedIds.isEmpty) {
_historyLog.i('No orphaned entries found'); _historyLog.i('No orphaned entries found');
return 0; return 0;
} }
// Delete from database // Delete from database
final deletedCount = await _db.deleteByIds(orphanedIds); final deletedCount = await _db.deleteByIds(orphanedIds);
// Update in-memory state // Update in-memory state
final orphanedSet = orphanedIds.toSet(); final orphanedSet = orphanedIds.toSet();
state = state.copyWith( state = state.copyWith(
items: state.items.where((item) => !orphanedSet.contains(item.id)).toList(), items: state.items
.where((item) => !orphanedSet.contains(item.id))
.toList(),
); );
_historyLog.i('Cleaned up $deletedCount orphaned entries'); _historyLog.i('Cleaned up $deletedCount orphaned entries');
return deletedCount; return deletedCount;
} }
void clearHistory() { void clearHistory() {
state = DownloadHistoryState(); state = DownloadHistoryState();
_db.clearAll().catchError((e) { _db.clearAll().catchError((e) {
@@ -544,11 +600,13 @@ class _ProgressUpdate {
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double? speedMBps; final double? speedMBps;
final int? bytesReceived;
const _ProgressUpdate({ const _ProgressUpdate({
required this.status, required this.status,
required this.progress, required this.progress,
this.speedMBps, this.speedMBps,
this.bytesReceived,
}); });
} }
@@ -557,6 +615,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _downloadCount = 0; int _downloadCount = 0;
static const _cleanupInterval = 50; static const _cleanupInterval = 50;
static const _queueStorageKey = 'download_queue'; static const _queueStorageKey = 'download_queue';
static const _progressPollingInterval = Duration(milliseconds: 800);
static const _queueSchedulingInterval = Duration(milliseconds: 250);
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
int _totalQueuedAtStart = 0; int _totalQueuedAtStart = 0;
@@ -564,15 +624,33 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
int _failedInSession = 0; int _failedInSession = 0;
bool _isLoaded = false; bool _isLoaded = false;
final Set<String> _ensuredDirs = {}; final Set<String> _ensuredDirs = {};
int _progressPollingErrorCount = 0;
String? _lastServiceTrackName;
String? _lastServiceArtistName;
int _lastServicePercent = -1;
int _lastServiceQueueCount = -1;
DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
@override @override
DownloadQueueState build() { DownloadQueueState build() {
ref.listen<AppSettings>(settingsProvider, (previous, next) {
final previousConcurrent =
previous?.concurrentDownloads ?? state.concurrentDownloads;
updateSettings(next);
if (previousConcurrent != next.concurrentDownloads) {
_log.i(
'Concurrent downloads updated: $previousConcurrent -> ${next.concurrentDownloads}',
);
}
});
ref.onDispose(() { ref.onDispose(() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
}); });
Future.microtask(() async { Future.microtask(() async {
updateSettings(ref.read(settingsProvider));
await _initOutputDir(); await _initOutputDir();
await _loadQueueFromStorage(); await _loadQueueFromStorage();
}); });
@@ -647,9 +725,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void _startMultiProgressPolling() { void _startMultiProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), ( _progressTimer = Timer.periodic(_progressPollingInterval, (timer) async {
timer,
) async {
try { try {
final allProgress = await PlatformBridge.getAllDownloadProgress(); final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {}; final items = allProgress['items'] as Map<String, dynamic>? ?? {};
@@ -727,6 +803,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
status: DownloadStatus.downloading, status: DownloadStatus.downloading,
progress: percentage, progress: percentage,
speedMBps: speedMBps, speedMBps: speedMBps,
bytesReceived: bytesReceived,
); );
final mbReceived = bytesReceived / (1024 * 1024); final mbReceived = bytesReceived / (1024 * 1024);
@@ -761,10 +838,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
status: update.status, status: update.status,
progress: update.progress, progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps, speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived,
); );
if (current.status != next.status || if (current.status != next.status ||
current.progress != next.progress || current.progress != next.progress ||
current.speedMBps != next.speedMBps) { current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived) {
if (!changed) { if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems); updatedItems = List<DownloadItem>.from(updatedItems);
changed = true; changed = true;
@@ -818,23 +897,76 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
if (Platform.isAndroid) { if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress( _maybeUpdateAndroidDownloadService(
trackName: firstDownloading.track.name, trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName, artistName: firstDownloading.track.artistName,
progress: notifProgress, progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1, total: notifTotal > 0 ? notifTotal : 1,
queueCount: queuedCount, queueCount: queuedCount,
).catchError((_) {}); );
} }
} }
} }
} catch (_) {} _progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Progress polling failed: $e');
}
}
}); });
} }
void _maybeUpdateAndroidDownloadService({
required String trackName,
required String artistName,
required int progress,
required int total,
required int queueCount,
}) {
final now = DateTime.now();
final safeTotal = total > 0 ? total : 1;
final progressPercent = ((progress * 100) / safeTotal)
.round()
.clamp(0, 100)
.toInt();
final didContentChange =
trackName != _lastServiceTrackName ||
artistName != _lastServiceArtistName ||
queueCount != _lastServiceQueueCount ||
progressPercent != _lastServicePercent;
final allowHeartbeat =
now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5);
if (!didContentChange && !allowHeartbeat) {
return;
}
_lastServiceTrackName = trackName;
_lastServiceArtistName = artistName;
_lastServicePercent = progressPercent;
_lastServiceQueueCount = queueCount;
_lastServiceUpdateAt = now;
PlatformBridge.updateDownloadServiceProgress(
trackName: trackName,
artistName: artistName,
progress: progress,
total: safeTotal,
queueCount: queueCount,
).catchError((_) {});
}
void _stopProgressPolling() { void _stopProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
_progressPollingErrorCount = 0;
_lastServiceTrackName = null;
_lastServiceArtistName = null;
_lastServicePercent = -1;
_lastServiceQueueCount = -1;
_lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
} }
Future<void> _initOutputDir() async { Future<void> _initOutputDir() async {
@@ -900,14 +1032,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, { String folderOrganization, {
bool separateSingles = false, bool separateSingles = false,
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
}) async { }) async {
String baseDir = state.outputDir; String baseDir = state.outputDir;
final albumArtist = final folderArtist = useAlbumArtistForFolders
_normalizeOptionalString(track.albumArtist) ?? track.artistName; ? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) { if (isSingle) {
@@ -965,7 +1099,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String subPath = ''; String subPath = '';
switch (folderOrganization) { switch (folderOrganization) {
case 'artist': case 'artist':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
subPath = artistName; subPath = artistName;
break; break;
case 'album': case 'album':
@@ -973,7 +1107,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
subPath = albumName; subPath = albumName;
break; break;
case 'artist_album': case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
subPath = '$artistName${Platform.pathSeparator}$albumName'; subPath = '$artistName${Platform.pathSeparator}$albumName';
break; break;
@@ -1017,13 +1151,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, { String folderOrganization, {
bool separateSingles = false, bool separateSingles = false,
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
}) async { }) async {
final albumArtist = final folderArtist = useAlbumArtistForFolders
_normalizeOptionalString(track.albumArtist) ?? track.artistName; ? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) { if (isSingle) {
@@ -1059,11 +1195,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
switch (folderOrganization) { switch (folderOrganization) {
case 'artist': case 'artist':
return _sanitizeFolderName(albumArtist); return _sanitizeFolderName(folderArtist);
case 'album': case 'album':
return _sanitizeFolderName(track.albumName); return _sanitizeFolderName(track.albumName);
case 'artist_album': case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
return '$artistName/$albumName'; return '$artistName/$albumName';
default: default:
@@ -1072,6 +1208,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String _determineOutputExt(String quality, String service) { String _determineOutputExt(String quality, String service) {
// YouTube provider - lossy only (Opus or MP3)
if (service.toLowerCase() == 'youtube') {
if (quality.toLowerCase().contains('mp3')) {
return '.mp3';
}
return '.opus';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a'; return '.m4a';
} }
@@ -1087,8 +1230,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case '.opus': case '.opus':
return 'audio/ogg'; return 'audio/ogg';
case '.flac': case '.flac':
default:
return 'audio/flac'; return 'audio/flac';
case '.lrc':
return 'application/octet-stream';
default:
return 'application/octet-stream';
} }
} }
@@ -1107,7 +1253,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return match?.group(1); return match?.group(1);
} }
static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$');
bool _isValidISRC(String value) {
return _isrcRegex.hasMatch(value.toUpperCase());
}
void updateSettings(AppSettings settings) { void updateSettings(AppSettings settings) {
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
state = state.copyWith( state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty outputDir: settings.downloadDirectory.isNotEmpty
? settings.downloadDirectory ? settings.downloadDirectory
@@ -1115,7 +1268,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filenameFormat: settings.filenameFormat, filenameFormat: settings.filenameFormat,
audioQuality: settings.audioQuality, audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback, autoFallback: settings.autoFallback,
concurrentDownloads: settings.concurrentDownloads, concurrentDownloads: concurrentDownloads,
); );
} }
@@ -2035,7 +2188,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
treeUri: treeUri, treeUri: treeUri,
relativeDir: relativeDir, relativeDir: relativeDir,
fileName: lrcName, fileName: lrcName,
mimeType: 'text/plain', mimeType: _mimeTypeForExt('.lrc'),
srcPath: tempPath, srcPath: tempPath,
); );
if (uri != null) { if (uri != null) {
@@ -2064,6 +2217,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Check network connectivity before starting // Check network connectivity before starting
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
updateSettings(settings);
final isSafMode = _isSafMode(settings); final isSafMode = _isSafMode(settings);
if (settings.downloadNetworkMode == 'wifi_only') { if (settings.downloadNetworkMode == 'wifi_only') {
final connectivityResult = await Connectivity().checkConnectivity(); final connectivityResult = await Connectivity().checkConnectivity();
@@ -2123,6 +2277,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await musicDir.create(recursive: true); await musicDir.create(recursive: true);
} }
state = state.copyWith(outputDir: musicDir.path); state = state.copyWith(outputDir: musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/)
_log.w(
'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
);
_log.w('Original path: ${state.outputDir}');
final correctedPath = await validateOrFixIosPath(state.outputDir);
_log.i('Corrected path: $correctedPath');
state = state.copyWith(outputDir: correctedPath);
} }
} }
@@ -2174,12 +2337,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
_log.d('Concurrent downloads: ${state.concurrentDownloads}'); _log.d('Concurrent downloads: ${state.concurrentDownloads}');
await _processQueueParallel();
if (state.concurrentDownloads > 1) {
await _processQueueParallel();
} else {
await _processQueueSequential();
}
_stopProgressPolling(); _stopProgressPolling();
@@ -2235,56 +2393,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
Future<void> _processQueueSequential() async {
_startMultiProgressPolling();
while (true) {
if (state.isPaused) {
_log.d('Queue is paused, waiting...');
await Future.delayed(const Duration(milliseconds: 500));
continue;
}
final currentItems = state.items;
final nextIndex = currentItems.indexWhere(
(item) => item.status == DownloadStatus.queued,
);
if (nextIndex == -1) {
_log.d(
'No more items to process (checked ${currentItems.length} items)',
);
break;
}
final nextItem = currentItems[nextIndex];
_log.d(
'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})',
);
await _downloadSingleItem(nextItem);
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
}
_stopProgressPolling();
}
Future<void> _processQueueParallel() async { Future<void> _processQueueParallel() async {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{}; final activeDownloads = <String, Future<void>>{};
var lastLoggedMaxConcurrent = -1;
_startMultiProgressPolling(); _startMultiProgressPolling();
while (true) { while (true) {
if (state.isPaused) { if (state.isPaused) {
_log.d('Queue is paused, waiting for active downloads...'); _log.d('Queue is paused, waiting for active downloads...');
if (activeDownloads.isNotEmpty) { await Future.delayed(_queueSchedulingInterval);
await Future.any(activeDownloads.values);
} else {
await Future.delayed(const Duration(milliseconds: 500));
}
continue; continue;
} }
final maxConcurrent = max(1, state.concurrentDownloads);
if (lastLoggedMaxConcurrent != maxConcurrent) {
_log.d('Parallel worker max concurrency now: $maxConcurrent');
lastLoggedMaxConcurrent = maxConcurrent;
}
final queuedItems = state.items final queuedItems = state.items
.where((item) => item.status == DownloadStatus.queued) .where((item) => item.status == DownloadStatus.queued)
.toList(); .toList();
@@ -2313,7 +2440,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
if (activeDownloads.isNotEmpty) { if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values); // Re-check queue/settings periodically so concurrency changes
// (e.g. 1 -> 3) can take effect before any active item finishes.
await Future.any([
Future.any(activeDownloads.values),
Future.delayed(_queueSchedulingInterval),
]);
} else {
await Future.delayed(_queueSchedulingInterval);
} }
} }
@@ -2430,6 +2564,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
) )
: ''; : '';
String? appOutputDir; String? appOutputDir;
@@ -2440,6 +2575,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
); );
var effectiveOutputDir = initialOutputDir; var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode; var effectiveSafMode = isSafMode;
@@ -2477,7 +2613,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (deezerTrackId == null && if (deezerTrackId == null &&
trackToDownload.isrc != null && trackToDownload.isrc != null &&
trackToDownload.isrc!.isNotEmpty) { trackToDownload.isrc!.isNotEmpty &&
_isValidISRC(trackToDownload.isrc!)) {
try { try {
_log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}');
final deezerResult = await PlatformBridge.searchDeezerByISRC( final deezerResult = await PlatformBridge.searchDeezerByISRC(
@@ -2493,6 +2630,75 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
if (deezerTrackId == null &&
trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) {
try {
// Extract clean Spotify ID (remove spotify: prefix if present)
String spotifyId = trackToDownload.id;
if (spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.split(':').last;
}
_log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId');
final deezerData = await PlatformBridge.convertSpotifyToDeezer('track', spotifyId);
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
final trackData = deezerData['track'];
if (trackData is Map<String, dynamic>) {
final rawId = trackData['spotify_id'] as String?;
if (rawId != null && rawId.startsWith('deezer:')) {
deezerTrackId = rawId.split(':')[1];
_log.d('Found Deezer track ID via SongLink: $deezerTrackId');
} else if (deezerData['id'] != null) {
deezerTrackId = deezerData['id'].toString();
_log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId');
}
// Enrich track metadata from Deezer response (release_date, isrc, etc.)
final deezerReleaseDate = _normalizeOptionalString(trackData['release_date'] as String?);
final deezerIsrc = _normalizeOptionalString(trackData['isrc'] as String?);
final deezerTrackNum = trackData['track_number'] as int?;
final deezerDiscNum = trackData['disc_number'] as int?;
final needsEnrich =
(trackToDownload.releaseDate == null && deezerReleaseDate != null) ||
(trackToDownload.isrc == null && deezerIsrc != null) ||
(!_isValidISRC(trackToDownload.isrc ?? '') && deezerIsrc != null) ||
(trackToDownload.trackNumber == null && deezerTrackNum != null) ||
(trackToDownload.discNumber == null && deezerDiscNum != null);
if (needsEnrich) {
trackToDownload = Track(
id: trackToDownload.id,
name: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc
: trackToDownload.isrc,
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
deezerId: deezerTrackId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
source: trackToDownload.source,
);
_log.d('Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}');
}
} else if (deezerData['id'] != null) {
deezerTrackId = deezerData['id'].toString();
_log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId');
}
} catch (e) {
_log.w('Failed to convert Spotify to Deezer via SongLink: $e');
}
}
if (deezerTrackId != null && deezerTrackId.isNotEmpty) { if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
try { try {
final extendedMetadata = final extendedMetadata =
@@ -2528,6 +2734,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final fileName = useSaf ? (safFileName ?? '') : ''; final fileName = useSaf ? (safFileName ?? '') : '';
final outputExt = useSaf ? safOutputExt : ''; final outputExt = useSaf ? safOutputExt : '';
// YouTube provider - lossy only, bypasses fallback chain
if (item.service == 'youtube') {
_log.d('Using YouTube/Cobalt provider for download');
_log.d('Quality: $quality (lossy only)');
_log.d('Output dir: $outputDir');
return PlatformBridge.downloadFromYouTube(
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
itemId: item.id,
durationMs: trackToDownload.duration,
isrc: trackToDownload.isrc,
spotifyId: trackToDownload.id,
deezerId: deezerTrackId,
storageMode: storageMode,
safTreeUri: treeUri,
safRelativeDir: relativeDir,
safFileName: fileName,
safOutputExt: outputExt,
);
}
if (useExtensions) { if (useExtensions) {
_log.d('Using extension providers for download'); _log.d('Using extension providers for download');
_log.d( _log.d(
@@ -2636,6 +2872,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
); );
final fallbackResult = await runDownload( final fallbackResult = await runDownload(
useSaf: false, useSaf: false,
@@ -2713,6 +2950,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(filePath.endsWith('.flac') || (filePath.endsWith('.flac') ||
(mimeType != null && mimeType.contains('flac'))); (mimeType != null && mimeType.contains('flac')));
final shouldForceTidalSafM4aHandling = final shouldForceTidalSafM4aHandling =
!wasExisting &&
isContentUriPath && isContentUriPath &&
effectiveSafMode && effectiveSafMode &&
actualService == 'tidal' && actualService == 'tidal' &&
@@ -3125,6 +3363,109 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await File(tempPath).delete(); await File(tempPath).delete();
} catch (_) {} } catch (_) {}
} }
}
}
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
if (!wasExisting &&
item.service == 'youtube' &&
filePath != null) {
final isOpusFile = filePath.endsWith('.opus');
final isMp3File = filePath.endsWith('.mp3');
if (isOpusFile || isMp3File) {
_log.i('YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final isContentUriPath = isContentUri(filePath);
if (isContentUriPath && effectiveSafMode) {
// SAF mode: copy to temp, embed, write back
final tempPath = await _copySafToTemp(filePath);
if (tempPath != null) {
try {
if (isMp3File) {
await _embedMetadataToMp3(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
// Write back to SAF
final ext = isMp3File ? '.mp3' : '.opus';
final newFileName = '${safBaseName ?? 'track'}$ext';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt(ext),
srcPath: tempPath,
);
if (newUri != null) {
if (newUri != filePath) {
await _deleteSafFile(filePath);
}
filePath = newUri;
finalSafFileName = newFileName;
_log.d('YouTube SAF metadata embedding completed');
} else {
_log.w('Failed to write metadata-updated file back to SAF');
}
} catch (e) {
_log.w('YouTube SAF metadata embedding failed: $e');
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
}
}
} else {
// Non-SAF mode: embed directly
try {
if (isMp3File) {
await _embedMetadataToMp3(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('YouTube metadata embedding completed');
} catch (e) {
_log.w('YouTube metadata embedding failed: $e');
}
}
} }
} }
@@ -3344,6 +3685,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType: errorType, errorType: errorType,
); );
_failedInSession++; _failedInSession++;
// Immediately cleanup connections after failure to prevent
// poisoned connection pool from affecting subsequent downloads
try {
await PlatformBridge.cleanupConnections();
} catch (e) {
_log.e('Post-failure connection cleanup failed: $e');
}
} }
_downloadCount++; _downloadCount++;
@@ -3385,6 +3734,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
errorType: errorType, errorType: errorType,
); );
_failedInSession++; _failedInSession++;
// Immediately cleanup connections after exception
try {
await PlatformBridge.cleanupConnections();
} catch (cleanupErr) {
_log.e('Post-exception connection cleanup failed: $cleanupErr');
}
} }
} }
} }
+148 -69
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary'); final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at'; const _lastScannedAtKey = 'local_library_last_scanned_at';
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
class LocalLibraryState { class LocalLibraryState {
final List<LocalLibraryItem> items; final List<LocalLibraryItem> items;
@@ -22,6 +23,7 @@ class LocalLibraryState {
final int scanErrorCount; final int scanErrorCount;
final bool scanWasCancelled; final bool scanWasCancelled;
final DateTime? lastScannedAt; final DateTime? lastScannedAt;
final int excludedDownloadedCount;
final Set<String> _isrcSet; final Set<String> _isrcSet;
final Set<String> _trackKeySet; final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc; final Map<String, LocalLibraryItem> _byIsrc;
@@ -36,16 +38,17 @@ class LocalLibraryState {
this.scanErrorCount = 0, this.scanErrorCount = 0,
this.scanWasCancelled = false, this.scanWasCancelled = false,
this.lastScannedAt, this.lastScannedAt,
}) : _isrcSet = items this.excludedDownloadedCount = 0,
.where((item) => item.isrc != null && item.isrc!.isNotEmpty) }) : _isrcSet = items
.map((item) => item.isrc!) .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.toSet(), .map((item) => item.isrc!)
_trackKeySet = items.map((item) => item.matchKey).toSet(), .toSet(),
_byIsrc = Map.fromEntries( _trackKeySet = items.map((item) => item.matchKey).toSet(),
items _byIsrc = Map.fromEntries(
.where((item) => item.isrc != null && item.isrc!.isNotEmpty) items
.map((item) => MapEntry(item.isrc!, item)), .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
); .map((item) => MapEntry(item.isrc!, item)),
);
bool hasIsrc(String isrc) => _isrcSet.contains(isrc); bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
@@ -81,6 +84,7 @@ class LocalLibraryState {
int? scanErrorCount, int? scanErrorCount,
bool? scanWasCancelled, bool? scanWasCancelled,
DateTime? lastScannedAt, DateTime? lastScannedAt,
int? excludedDownloadedCount,
}) { }) {
return LocalLibraryState( return LocalLibraryState(
items: items ?? this.items, items: items ?? this.items,
@@ -92,6 +96,8 @@ class LocalLibraryState {
scanErrorCount: scanErrorCount ?? this.scanErrorCount, scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
lastScannedAt: lastScannedAt ?? this.lastScannedAt, lastScannedAt: lastScannedAt ?? this.lastScannedAt,
excludedDownloadedCount:
excludedDownloadedCount ?? this.excludedDownloadedCount,
); );
} }
} }
@@ -99,9 +105,11 @@ class LocalLibraryState {
class LocalLibraryNotifier extends Notifier<LocalLibraryState> { class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance; final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance;
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer; Timer? _progressTimer;
bool _isLoaded = false; bool _isLoaded = false;
bool _scanCancelRequested = false; bool _scanCancelRequested = false;
int _progressPollingErrorCount = 0;
@override @override
LocalLibraryState build() { LocalLibraryState build() {
@@ -121,23 +129,31 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final jsonList = await _db.getAll(); final jsonList = await _db.getAll();
final items = jsonList final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
.map((e) => LocalLibraryItem.fromJson(e))
.toList();
DateTime? lastScannedAt; DateTime? lastScannedAt;
var excludedDownloadedCount = 0;
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final lastScannedAtStr = prefs.getString(_lastScannedAtKey); final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) { if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr); lastScannedAt = DateTime.tryParse(lastScannedAtStr);
} }
excludedDownloadedCount =
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
} catch (e) { } catch (e) {
_log.w('Failed to load lastScannedAt: $e'); _log.w('Failed to load lastScannedAt: $e');
} }
state = state.copyWith(items: items, lastScannedAt: lastScannedAt); state = state.copyWith(
_log.i('Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt'); items: items,
lastScannedAt: lastScannedAt,
excludedDownloadedCount: excludedDownloadedCount,
);
_log.i(
'Loaded ${items.length} items from library database, lastScannedAt: '
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
);
} catch (e, stack) { } catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack); _log.e('Failed to load library from database: $e', e, stack);
} }
@@ -148,14 +164,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase(); await _loadFromDatabase();
} }
Future<void> startScan(String folderPath, {bool forceFullScan = false}) async { Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
}) async {
if (state.isScanning) { if (state.isScanning) {
_log.w('Scan already in progress'); _log.w('Scan already in progress');
return; return;
} }
_scanCancelRequested = false; _scanCancelRequested = false;
_log.i('Starting library scan: $folderPath (incremental: ${!forceFullScan})'); _log.i(
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
);
state = state.copyWith( state = state.copyWith(
isScanning: true, isScanning: true,
scanProgress: 0, scanProgress: 0,
@@ -167,8 +188,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
try { try {
final cacheDir = await getApplicationCacheDirectory(); final appSupportDir = await getApplicationSupportDirectory();
final coverCacheDir = '${cacheDir.path}/library_covers'; final coverCacheDir = '${appSupportDir.path}/library_covers';
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir); await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
_log.i('Cover cache directory set to: $coverCacheDir'); _log.i('Cover cache directory set to: $coverCacheDir');
} catch (e) { } catch (e) {
@@ -179,11 +200,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final isSaf = folderPath.startsWith('content://'); 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
final downloadedPaths = await _historyDb.getAllFilePaths(); final downloadedPaths = await _historyDb.getAllFilePaths();
_log.i('Excluding ${downloadedPaths.length} downloaded files from library scan'); _log.i(
'Excluding ${downloadedPaths.length} downloaded files from library scan',
);
if (forceFullScan) { if (forceFullScan) {
// Full scan path - ignores existing data // Full scan path - ignores existing data
final results = isSaf final results = isSaf
@@ -193,7 +216,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
return; return;
} }
final items = <LocalLibraryItem>[]; final items = <LocalLibraryItem>[];
int skippedDownloads = 0; int skippedDownloads = 0;
for (final json in results) { for (final json in results) {
@@ -206,7 +229,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final item = LocalLibraryItem.fromJson(json); final item = LocalLibraryItem.fromJson(json);
items.add(item); items.add(item);
} }
if (skippedDownloads > 0) { if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history'); _log.i('Skipped $skippedDownloads files already in download history');
} }
@@ -217,6 +240,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String()); await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now'); _log.d('Saved lastScannedAt: $now');
} catch (e) { } catch (e) {
_log.w('Failed to save lastScannedAt: $e'); _log.w('Failed to save lastScannedAt: $e');
@@ -228,13 +252,19 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
); );
_log.i('Full scan complete: ${items.length} tracks found'); _log.i(
'Full scan complete: ${items.length} tracks found, '
'$skippedDownloads already in downloads',
);
} else { } else {
// Incremental scan path - only scans new/modified files // Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes(); final existingFiles = await _db.getFileModTimes();
_log.i('Incremental scan: ${existingFiles.length} existing files in database'); _log.i(
'Incremental scan: ${existingFiles.length} existing files in database',
);
final backfilledModTimes = await _backfillLegacyFileModTimes( final backfilledModTimes = await _backfillLegacyFileModTimes(
isSaf: isSaf, isSaf: isSaf,
@@ -245,7 +275,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
existingFiles.addAll(backfilledModTimes); existingFiles.addAll(backfilledModTimes);
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times'); _log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
} }
// Use appropriate incremental scan method based on SAF or not // Use appropriate incremental scan method based on SAF or not
final Map<String, dynamic> result; final Map<String, dynamic> result;
if (isSaf) { if (isSaf) {
@@ -259,67 +289,81 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
existingFiles, existingFiles,
); );
} }
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(isScanning: false, scanWasCancelled: true);
return; return;
} }
// Parse incremental scan result // Parse incremental scan result
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList = (result['files'] as List<dynamic>?) final scannedList =
?? (result['scanned'] as List<dynamic>?) (result['files'] as List<dynamic>?) ??
?? []; (result['scanned'] as List<dynamic>?) ??
final deletedPaths = (result['removedUris'] as List<dynamic>?) [];
?.map((e) => e as String) final deletedPaths =
.toList() (result['removedUris'] as List<dynamic>?)
?? (result['deletedPaths'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() .toList() ??
?? []; (result['deletedPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
final skippedCount = result['skippedCount'] as int? ?? 0; final skippedCount = result['skippedCount'] as int? ?? 0;
final totalFiles = result['totalFiles'] as int? ?? 0; final totalFiles = result['totalFiles'] as int? ?? 0;
_log.i('Incremental result: ${scannedList.length} scanned, ' _log.i(
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total'); 'Incremental result: ${scannedList.length} scanned, '
'$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total',
);
final currentByPath = <String, LocalLibraryItem>{
for (final item in state.items) item.filePath: item,
};
// Upsert new/modified items (excluding downloaded files) // Upsert new/modified items (excluding downloaded files)
final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = 0;
if (scannedList.isNotEmpty) { if (scannedList.isNotEmpty) {
final items = <LocalLibraryItem>[];
int skippedDownloads = 0;
for (final json in scannedList) { for (final json in scannedList) {
final map = json as Map<String, dynamic>; final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?; final filePath = map['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) { if (filePath != null && downloadedPaths.contains(filePath)) {
skippedDownloads++; skippedDownloads++;
continue; continue;
} }
items.add(LocalLibraryItem.fromJson(map)); final item = LocalLibraryItem.fromJson(map);
updatedItems.add(item);
currentByPath[item.filePath] = item;
} }
if (items.isNotEmpty) { if (updatedItems.isNotEmpty) {
await _db.upsertBatch(items.map((e) => e.toJson()).toList()); await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
_log.i('Upserted ${items.length} items'); _log.i('Upserted ${updatedItems.length} items');
} }
if (skippedDownloads > 0) { if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history'); _log.i(
'Skipped $skippedDownloads files already in download history',
);
} }
} }
// Delete removed items // Delete removed items
if (deletedPaths.isNotEmpty) { if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths); final deleteCount = await _db.deleteByPaths(deletedPaths);
for (final path in deletedPaths) {
currentByPath.remove(path);
}
_log.i('Deleted $deleteCount items from database'); _log.i('Deleted $deleteCount items from database');
} }
// Reload all items from database to get complete list final items = currentByPath.values.toList(growable: false)
final allItems = await _db.getAll(); ..sort(_compareLibraryItems);
final items = allItems.map((e) => LocalLibraryItem.fromJson(e)).toList();
final now = DateTime.now(); final now = DateTime.now();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String()); await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now'); _log.d('Saved lastScannedAt: $now');
} catch (e) { } catch (e) {
_log.w('Failed to save lastScannedAt: $e'); _log.w('Failed to save lastScannedAt: $e');
@@ -331,10 +375,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
); );
_log.i('Incremental scan complete: ${items.length} total tracks ' _log.i(
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)'); 'Incremental scan complete: ${items.length} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
);
} }
} catch (e, stack) { } catch (e, stack) {
_log.e('Library scan failed: $e', e, stack); _log.e('Library scan failed: $e', e, stack);
@@ -346,10 +394,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
void _startProgressPolling() { void _startProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { _progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
try { try {
final progress = await PlatformBridge.getLibraryScanProgress(); final progress = await PlatformBridge.getLibraryScanProgress();
state = state.copyWith( state = state.copyWith(
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0, scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
scanCurrentFile: progress['current_file'] as String?, scanCurrentFile: progress['current_file'] as String?,
@@ -361,18 +409,25 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (progress['is_complete'] == true) { if (progress['is_complete'] == true) {
_stopProgressPolling(); _stopProgressPolling();
} }
} catch (_) {} _progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Library scan progress polling failed: $e');
}
}
}); });
} }
void _stopProgressPolling() { void _stopProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = null; _progressTimer = null;
_progressPollingErrorCount = 0;
} }
Future<void> cancelScan() async { Future<void> cancelScan() async {
if (!state.isScanning) return; if (!state.isScanning) return;
_log.i('Cancelling library scan'); _log.i('Cancelling library scan');
_scanCancelRequested = true; _scanCancelRequested = true;
await PlatformBridge.cancelLibraryScan(); await PlatformBridge.cancelLibraryScan();
@@ -390,14 +445,15 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<void> clearLibrary() async { Future<void> clearLibrary() async {
await _db.clearAll(); await _db.clearAll();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey); await prefs.remove(_lastScannedAtKey);
await prefs.remove(_excludedDownloadedCountKey);
} catch (e) { } catch (e) {
_log.w('Failed to clear lastScannedAt: $e'); _log.w('Failed to clear lastScannedAt: $e');
} }
state = LocalLibraryState(); state = LocalLibraryState();
_log.i('Library cleared'); _log.i('Library cleared');
} }
@@ -421,7 +477,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return state.getByIsrc(isrc); return state.getByIsrc(isrc);
} }
LocalLibraryItem? findExisting({String? isrc, String? trackName, String? artistName}) { LocalLibraryItem? findExisting({
String? isrc,
String? trackName,
String? artistName,
}) {
if (isrc != null && isrc.isNotEmpty) { if (isrc != null && isrc.isNotEmpty) {
final byIsrc = state.getByIsrc(isrc); final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc; if (byIsrc != null) return byIsrc;
@@ -434,7 +494,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<List<LocalLibraryItem>> search(String query) async { Future<List<LocalLibraryItem>> search(String query) async {
if (query.isEmpty) return []; if (query.isEmpty) return [];
final results = await _db.search(query); final results = await _db.search(query);
return results.map((e) => LocalLibraryItem.fromJson(e)).toList(); return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
} }
@@ -443,6 +503,23 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
return await _db.getCount(); return await _db.getCount();
} }
int _compareLibraryItems(LocalLibraryItem a, LocalLibraryItem b) {
final artistA = (a.albumArtist ?? a.artistName).toLowerCase();
final artistB = (b.albumArtist ?? b.artistName).toLowerCase();
final artistCompare = artistA.compareTo(artistB);
if (artistCompare != 0) return artistCompare;
final albumCompare = a.albumName.toLowerCase().compareTo(
b.albumName.toLowerCase(),
);
if (albumCompare != 0) return albumCompare;
final discCompare = (a.discNumber ?? 0).compareTo(b.discNumber ?? 0);
if (discCompare != 0) return discCompare;
return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0);
}
Future<Map<String, int>> _backfillLegacyFileModTimes({ Future<Map<String, int>> _backfillLegacyFileModTimes({
required bool isSaf, required bool isSaf,
required Map<String, int> existingFiles, required Map<String, int> existingFiles,
@@ -469,7 +546,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (_scanCancelRequested) { if (_scanCancelRequested) {
break; break;
} }
final end = (i + chunkSize < uris.length) ? i + chunkSize : uris.length; final end = (i + chunkSize < uris.length)
? i + chunkSize
: uris.length;
final chunk = uris.sublist(i, end); final chunk = uris.sublist(i, end);
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk); final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
backfilled.addAll(chunkResult); backfilled.addAll(chunkResult);
+51 -19
View File
@@ -10,10 +10,14 @@ const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version'; const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 2; const _currentMigrationVersion = 2;
const _spotifyClientSecretKey = 'spotify_client_secret'; const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> { class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance(); final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage(); final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
bool _isSavingSettings = false;
bool _saveQueued = false;
String? _pendingSettingsJson;
@override @override
AppSettings build() { AppSettings build() {
@@ -26,27 +30,27 @@ class SettingsNotifier extends Notifier<AppSettings> {
final json = prefs.getString(_settingsKey); final json = prefs.getString(_settingsKey);
if (json != null) { if (json != null) {
state = AppSettings.fromJson(jsonDecode(json)); state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs); await _runMigrations(prefs);
} }
await _loadSpotifyClientSecret(prefs); await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials(); _applySpotifyCredentials();
LogBuffer.loggingEnabled = state.enableLogging; LogBuffer.loggingEnabled = state.enableLogging;
} }
Future<void> _runMigrations(SharedPreferences prefs) async { Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0; final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) { if (lastMigration < 1) {
if (!state.useCustomSpotifyCredentials) { if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer'); state = state.copyWith(metadataSource: 'deezer');
await _saveSettings(); await _saveSettings();
} }
} }
if (lastMigration < _currentMigrationVersion) { if (lastMigration < _currentMigrationVersion) {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') { if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf'); state = state.copyWith(storageMode: 'saf');
@@ -61,20 +65,43 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _saveSettings() async { Future<void> _saveSettings() async {
final prefs = await _prefs; final settingsToSave = state.copyWith(spotifyClientSecret: '');
final settingsToSave = state.copyWith( _pendingSettingsJson = jsonEncode(settingsToSave.toJson());
spotifyClientSecret: '',
); if (_isSavingSettings) {
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson())); _saveQueued = true;
return;
}
_isSavingSettings = true;
try {
final prefs = await _prefs;
do {
final jsonToWrite = _pendingSettingsJson;
_saveQueued = false;
if (jsonToWrite != null) {
await prefs.setString(_settingsKey, jsonToWrite);
}
} while (_saveQueued);
} catch (e) {
_log.e('Failed to save settings: $e');
} finally {
_isSavingSettings = false;
}
} }
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async { Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey); final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
final prefsSecret = state.spotifyClientSecret; final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) && if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) { prefsSecret.isNotEmpty) {
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret); await _secureStorage.write(
key: _spotifyClientSecretKey,
value: prefsSecret,
);
} }
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty) final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
@@ -99,7 +126,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> _applySpotifyCredentials() async { Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty && if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) { state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials( await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId, state.spotifyClientId,
@@ -172,7 +199,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
void setConcurrentDownloads(int count) { void setConcurrentDownloads(int count) {
final clamped = count.clamp(1, 3); final clamped = count.clamp(1, 5);
state = state.copyWith(concurrentDownloads: clamped); state = state.copyWith(concurrentDownloads: clamped);
_saveSettings(); _saveSettings();
} }
@@ -199,6 +226,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) { void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode); state = state.copyWith(historyViewMode: mode);
_saveSettings(); _saveSettings();
@@ -225,7 +257,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async { Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
state = state.copyWith( state = state.copyWith(
spotifyClientId: clientId, spotifyClientId: clientId,
spotifyClientSecret: clientSecret, spotifyClientSecret: clientSecret,
@@ -236,10 +271,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
} }
Future<void> clearSpotifyCredentials() async { Future<void> clearSpotifyCredentials() async {
state = state.copyWith( state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
spotifyClientId: '',
spotifyClientSecret: '',
);
await _storeSpotifyClientSecret(''); await _storeSpotifyClientSecret('');
_saveSettings(); _saveSettings();
_applySpotifyCredentials(); _applySpotifyCredentials();
@@ -301,7 +333,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setUseAllFilesAccess(bool enabled) { void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled); state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings(); _saveSettings();
} }
+419 -160
View File
@@ -1,7 +1,8 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
@@ -14,7 +15,8 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen; import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionArtistScreen;
class _AlbumCache { class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {}; static final Map<String, _CacheEntry> _cache = {};
@@ -69,7 +71,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks; List<Track>? _tracks;
bool _isLoading = false; bool _isLoading = false;
String? _error; String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
String? _artistId; String? _artistId;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -77,34 +78,35 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
// Use extensionId if available, otherwise detect from albumId prefix // Use extensionId if available, otherwise detect from albumId prefix
final providerId = widget.extensionId ?? final providerId =
widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify'); (widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordAlbumAccess( ref
id: widget.albumId, .read(recentAccessProvider.notifier)
name: widget.albumName, .recordAlbumAccess(
artistName: widget.tracks?.firstOrNull?.artistName, id: widget.albumId,
imageUrl: widget.coverUrl, name: widget.albumName,
providerId: providerId, artistName: widget.tracks?.firstOrNull?.artistName,
); imageUrl: widget.coverUrl,
providerId: providerId,
);
}); });
if (widget.tracks != null && widget.tracks!.isNotEmpty) { if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks; _tracks = widget.tracks;
} else { } else {
_tracks = _AlbumCache.get(widget.albumId); _tracks = _AlbumCache.get(widget.albumId);
} }
_artistId = widget.artistId; _artistId = widget.artistId;
if (_tracks == null || _tracks!.isEmpty) { if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks(); _fetchTracks();
} }
_extractDominantColor();
} }
@override @override
@@ -121,14 +123,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
} }
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
String _formatReleaseDate(String date) { String _formatReleaseDate(String date) {
if (date.length >= 10) { if (date.length >= 10) {
final parts = date.substring(0, 10).split('-'); final parts = date.substring(0, 10).split('-');
@@ -144,27 +138,32 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return date; return date;
} }
Future<void> _fetchTracks() async { Future<void> _fetchTracks() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
Map<String, dynamic> metadata; Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) { if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId); metadata = await PlatformBridge.getDeezerMetadata(
'album',
deezerAlbumId,
);
} else { } else {
final url = 'https://open.spotify.com/album/${widget.albumId}'; final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url); metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
} }
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?; final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?; final artistId = albumInfo?['artist_id'] as String?;
_AlbumCache.set(widget.albumId, tracks); _AlbumCache.set(widget.albumId, tracks);
if (mounted) { if (mounted) {
setState(() { setState(() {
_tracks = tracks; _tracks = tracks;
@@ -210,15 +209,19 @@ Future<void> _fetchTracks() async {
_buildAppBar(context, colorScheme), _buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme), _buildInfoCard(context, colorScheme),
if (_isLoading) if (_isLoading)
const SliverToBoxAdapter(child: Padding( const SliverToBoxAdapter(
padding: EdgeInsets.all(32), child: Padding(
child: Center(child: CircularProgressIndicator()), padding: EdgeInsets.all(32),
)), child: Center(child: CircularProgressIndicator()),
if (_error != null) ),
SliverToBoxAdapter(child: Padding( ),
padding: const EdgeInsets.all(16), if (_error != null)
child: _buildErrorWidget(_error!, colorScheme), SliverToBoxAdapter(
)), child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme), _buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks),
@@ -230,12 +233,17 @@ Future<void> _fetchTracks() async {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; final screenWidth = mediaSize.width;
final bgColor = _dominantColor ?? colorScheme.surface; final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -256,26 +264,52 @@ Future<void> _fetchTracks() async {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
collapseMode: CollapseMode.none, collapseMode: CollapseMode.none,
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
AnimatedContainer( // Blurred cover background
duration: const Duration(milliseconds: 500), if (widget.coverUrl != null)
decoration: BoxDecoration( CachedNetworkImage(
gradient: LinearGradient( imageUrl: widget.coverUrl!,
begin: Alignment.topCenter, fit: BoxFit.cover,
end: Alignment.bottomCenter, cacheManager: CoverCacheManager.instance,
colors: [ placeholder: (_, _) =>
bgColor, Container(color: colorScheme.surface),
bgColor.withValues(alpha: 0.8), errorWidget: (_, _, _) =>
colorScheme.surface, Container(color: colorScheme.surface),
], )
stops: const [0.0, 0.6, 1.0], else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
Positioned(
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,
],
),
), ),
), ),
), ),
@@ -284,7 +318,7 @@ Future<void> _fetchTracks() async {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -301,15 +335,19 @@ Future<void> _fetchTracks() async {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -318,7 +356,10 @@ Future<void> _fetchTracks() async {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
@@ -326,7 +367,7 @@ Future<void> _fetchTracks() async {
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8), color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
@@ -336,18 +377,20 @@ Future<void> _fetchTracks() async {
); );
} }
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) { Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? []; final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null; final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null; final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -355,7 +398,10 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
children: [ children: [
Text( Text(
widget.albumName, widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
if (artistName != null && artistName.isNotEmpty) ...[ if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -376,27 +422,61 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
runSpacing: 8, runSpacing: 8,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer), Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
context.l10n.tracksCount(tracks.length),
style: TextStyle(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
if (releaseDate != null && releaseDate.isNotEmpty) if (releaseDate != null && releaseDate.isNotEmpty)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer), Icon(
Icons.calendar_today,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
_formatReleaseDate(releaseDate),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
@@ -410,7 +490,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
label: Text(context.l10n.downloadAllCount(tracks.length)), label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48), minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
), ),
), ),
], ],
@@ -430,28 +512,35 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
children: [ children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary), Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
], ],
), ),
), ),
); );
} }
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) { Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
) {
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { final track = tracks[index];
final track = tracks[index]; return KeyedSubtree(
return KeyedSubtree( key: ValueKey(track.id),
key: ValueKey(track.id), child: _AlbumTrackItem(
child: _AlbumTrackItem( track: track,
track: track, onDownload: () => _downloadTrack(context, track),
onDownload: () => _downloadTrack(context, track), ),
), );
); }, childCount: tracks.length),
},
childCount: tracks.length,
),
); );
} }
@@ -464,13 +553,23 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
artistName: track.artistName, artistName: track.artistName,
coverUrl: track.coverUrl, coverUrl: track.coverUrl,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); .read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); .read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
} }
} }
@@ -484,27 +583,44 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
trackName: '${tracks.length} tracks', trackName: '${tracks.length} tracks',
artistName: widget.albumName, artistName: widget.albumName,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); .read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)))); .read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
} }
} }
void _navigateToArtist(BuildContext context, String artistName) { void _navigateToArtist(BuildContext context, String artistName) {
final artistId = _artistId ?? final artistId =
_artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown'); (widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) { if (artistId == 'unknown' ||
artistId == 'deezer:unknown' ||
artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')), SnackBar(content: Text('Artist information not available')),
); );
return; return;
} }
if (widget.extensionId != null) { if (widget.extensionId != null) {
Navigator.push( Navigator.push(
context, context,
@@ -519,7 +635,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
); );
return; return;
} }
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -533,10 +649,11 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
} }
Widget _buildErrorWidget(String error, ColorScheme colorScheme) { Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') || final isRateLimit =
error.toLowerCase().contains('rate limit') || error.contains('429') ||
error.toLowerCase().contains('too many requests'); error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) { if (isRateLimit) {
return Card( return Card(
elevation: 0, elevation: 0,
@@ -575,7 +692,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
), ),
); );
} }
return Card( return Card(
elevation: 0, elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5), color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -586,7 +703,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
children: [ children: [
Icon(Icons.error_outline, color: colorScheme.error), Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))), Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
], ],
), ),
), ),
@@ -603,33 +722,44 @@ class _AlbumTrackItem extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch( final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), downloadQueueLookupProvider.select(
(lookup) => lookup.byTrackId[track.id],
),
); );
final isInHistory = ref.watch(downloadHistoryProvider.select((state) { final isInHistory = ref.watch(
return state.isDownloaded(track.id); downloadHistoryProvider.select((state) {
})); return state.isDownloaded(track.id);
}),
final settings = ref.watch(settingsProvider); );
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator final showLocalLibraryIndicator = ref.watch(
? ref.watch(localLibraryProvider.select((state) => settingsProvider.select(
state.existsInLibrary( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
isrc: track.isrc, ),
trackName: track.name, );
artistName: track.artistName, final isInLocalLibrary = showLocalLibraryIndicator
))) ? ref.watch(
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: false; : false;
final isQueued = queueItem != null; final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading; final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing; final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed; final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0; final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory); final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -637,8 +767,10 @@ class _AlbumTrackItem extends ConsumerWidget {
elevation: 0, elevation: 0,
color: Colors.transparent, color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: SizedBox( leading: SizedBox(
width: 32, width: 32,
child: Center( child: Center(
@@ -651,14 +783,31 @@ child: ListTile(
), ),
), ),
), ),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), title: Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row( subtitle: Row(
children: [ children: [
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))), Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary) ...[ if (isInLocalLibrary) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.tertiaryContainer, color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -666,51 +815,102 @@ child: ListTile(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer), Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3), const SizedBox(width: 3),
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)), Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
], ],
), ),
), ),
], ],
], ],
), ),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress), trailing: _buildDownloadButton(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), context,
ref,
colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
), ),
), ),
); );
} }
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async { void _handleTap(
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return; if (isQueued) return;
if (isInLocalLibrary) { if (isInLocalLibrary) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
} }
return; return;
} }
if (isInHistory) { if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) { if (historyItem != null) {
final exists = await fileExists(historyItem.filePath); final exists = await fileExists(historyItem.filePath);
if (exists) { if (exists) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
} }
return; return;
} else { } else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
} }
} }
} }
onDownload(); onDownload();
} }
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { Widget _buildDownloadButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued, required bool isQueued,
required bool isDownloading, required bool isDownloading,
required bool isFinalizing, required bool isFinalizing,
@@ -721,11 +921,29 @@ child: ListTile(
}) { }) {
const double size = 44.0; const double size = 44.0;
const double iconSize = 20.0; const double iconSize = 20.0;
if (showAsDownloaded) { if (showAsDownloaded) {
return GestureDetector( return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), onTap: () => _handleTap(
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)), context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
); );
} else if (isFinalizing) { } else if (isFinalizing) {
return SizedBox( return SizedBox(
@@ -734,7 +952,11 @@ child: ListTile(
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
], ],
), ),
@@ -746,17 +968,54 @@ child: ListTile(
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)), value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
], ],
), ),
); );
} else if (isQueued) { } else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize)); return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else { } else {
return GestureDetector( return GestureDetector(
onTap: onDownload, onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
); );
} }
} }
File diff suppressed because it is too large Load Diff
+305 -161
View File
@@ -1,9 +1,9 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -23,13 +23,13 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
}); });
@override @override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState(); ConsumerState<DownloadedAlbumScreen> createState() =>
_DownloadedAlbumScreenState();
} }
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> { class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bool _isSelectionMode = false; bool _isSelectionMode = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
@@ -37,7 +37,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_extractDominantColor();
} }
@override @override
@@ -54,51 +53,32 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
} }
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
// Check cache first (instant)
final cached = PaletteService.instance.getCached(widget.coverUrl);
if (cached != null) {
if (mounted && cached != _dominantColor) {
setState(() {
_dominantColor = cached;
});
}
return;
}
// Extract in isolate (non-blocking)
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
/// Get tracks for this album from history provider (reactive) /// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) { List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
) {
return allItems.where((item) { return allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName // Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) final itemArtist =
? item.albumArtist! (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName; : item.artistName;
// Use lowercase for case-insensitive matching // Use lowercase for case-insensitive matching
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; final itemKey =
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey =
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
return itemKey == albumKey; return itemKey == albumKey;
}).toList() }).toList()..sort((a, b) {
..sort((a, b) { // Sort by disc number first, then by track number
// Sort by disc number first, then by track number final aDisc = a.discNumber ?? 1;
final aDisc = a.discNumber ?? 1; final bDisc = b.discNumber ?? 1;
final bDisc = b.discNumber ?? 1; if (aDisc != bDisc) return aDisc.compareTo(bDisc);
if (aDisc != bDisc) return aDisc.compareTo(bDisc); final aNum = a.trackNumber ?? 999;
final aNum = a.trackNumber ?? 999; final bNum = b.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999; if (aNum != bNum) return aNum.compareTo(bNum);
if (aNum != bNum) return aNum.compareTo(bNum); return a.trackName.compareTo(b.trackName);
return a.trackName.compareTo(b.trackName); });
});
} }
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc( Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
@@ -172,7 +152,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier); final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final idsToDelete = _selectedIds.toList(); final idsToDelete = _selectedIds.toList();
int deletedCount = 0; int deletedCount = 0;
for (final id in idsToDelete) { for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull; final item = currentTracks.where((e) => e.id == id).firstOrNull;
@@ -184,12 +164,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
deletedCount++; deletedCount++;
} }
} }
_exitSelectionMode(); _exitSelectionMode();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))), SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
); );
} }
} }
@@ -201,7 +183,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))), SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
); );
} }
} }
@@ -209,12 +193,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void _navigateToMetadataScreen(DownloadHistoryItem item) { void _navigateToMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
Navigator.push(context, PageRouteBuilder( Navigator.push(
transitionDuration: const Duration(milliseconds: 300), context,
reverseTransitionDuration: const Duration(milliseconds: 250), PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), transitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), reverseTransitionDuration: const Duration(milliseconds: 250),
)); pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
} }
void _precacheCover(String? url) { void _precacheCover(String? url) {
@@ -232,22 +221,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom; final bottomPadding = MediaQuery.of(context).padding.bottom;
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items)); final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final tracks = _getAlbumTracks(allHistoryItems); final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found // Show empty state if no tracks found
if (tracks.isEmpty) { if (tracks.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(widget.albumName)),
title: Text(widget.albumName), body: Center(child: Text('No tracks found for this album')),
),
body: Center(
child: Text('No tracks found for this album'),
),
); );
} }
final validIds = tracks.map((t) => t.id).toSet(); final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id)); _selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) { if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -273,17 +260,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks), _buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
], ],
), ),
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
left: 0, left: 0,
right: 0, right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding), child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
), ),
], ],
), ),
@@ -292,15 +286,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; // 50% of screen width final screenWidth = mediaSize.width;
final bgColor = _dominantColor ?? colorScheme.surface; final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: AnimatedOpacity( title: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -318,27 +318,52 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
collapseMode: CollapseMode.none, collapseMode: CollapseMode.none,
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background with dominant color // Blurred cover background
AnimatedContainer( if (widget.coverUrl != null)
duration: const Duration(milliseconds: 500), CachedNetworkImage(
decoration: BoxDecoration( imageUrl: widget.coverUrl!,
gradient: LinearGradient( fit: BoxFit.cover,
begin: Alignment.topCenter, cacheManager: CoverCacheManager.instance,
end: Alignment.bottomCenter, placeholder: (_, _) =>
colors: [ Container(color: colorScheme.surface),
bgColor, errorWidget: (_, _, _) =>
bgColor.withValues(alpha: 0.8), Container(color: colorScheme.surface),
colorScheme.surface, )
], else
stops: const [0.0, 0.6, 1.0], Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
Positioned(
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,
],
),
), ),
), ),
), ),
@@ -348,7 +373,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -365,15 +390,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -382,14 +411,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -397,14 +432,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
} }
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -412,43 +453,70 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [ children: [
Text( Text(
widget.albumName, widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
widget.artistName, widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer), Icon(
Icons.download_done,
size: 14,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
context.l10n.downloadedAlbumDownloadedCount(
tracks.length,
),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (_getCommonQuality(tracks) != null) if (_getCommonQuality(tracks) != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24') color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer ? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest, : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
_getCommonQuality(tracks)!, _getCommonQuality(tracks)!,
style: TextStyle( style: TextStyle(
color: _getCommonQuality(tracks)!.startsWith('24') color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.onTertiaryContainer ? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 12, fontSize: 12,
@@ -475,7 +543,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return firstQuality; return firstQuality;
} }
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -483,14 +555,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [ children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary), Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), Text(
context.l10n.downloadedAlbumTracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(), const Spacer(),
if (!_isSelectionMode) if (!_isSelectionMode)
TextButton.icon( TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null, onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18), icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect), label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact), style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
), ),
], ],
), ),
@@ -498,21 +580,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
} }
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final discMap = _groupTracksByDisc(tracks); final discMap = _groupTracksByDisc(tracks);
if (discMap.length <= 1) { if (discMap.length <= 1) {
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { final track = tracks[index];
final track = tracks[index]; return KeyedSubtree(
return KeyedSubtree( key: ValueKey(track.id),
key: ValueKey(track.id), child: _buildTrackItem(context, colorScheme, track),
child: _buildTrackItem(context, colorScheme, track), );
); }, childCount: tracks.length),
},
childCount: tracks.length,
),
); );
} }
@@ -537,12 +620,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
} }
return SliverList( return SliverList(delegate: SliverChildListDelegate(children));
delegate: SliverChildListDelegate(children),
);
} }
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) { Widget _buildDiscSeparator(
BuildContext context,
ColorScheme colorScheme,
int discNumber,
) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row( child: Row(
@@ -556,7 +641,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer), Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
context.l10n.downloadedAlbumDiscHeader(discNumber), context.l10n.downloadedAlbumDiscHeader(discNumber),
@@ -580,21 +669,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
); );
} }
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) { Widget _buildTrackItem(
BuildContext context,
ColorScheme colorScheme,
DownloadHistoryItem track,
) {
final isSelected = _selectedIds.contains(track.id); final isSelected = _selectedIds.contains(track.id);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent, color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
onTap: _isSelectionMode borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track), : () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -603,12 +702,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
width: 24, width: 24,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent, color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
), ),
child: isSelected child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) ? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null, : null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -630,7 +740,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
track.trackName, track.trackName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
), ),
subtitle: Text( subtitle: Text(
track.artistName, track.artistName,
@@ -638,22 +750,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant), style: TextStyle(color: colorScheme.onSurfaceVariant),
), ),
trailing: _isSelectionMode ? null : IconButton( trailing: _isSelectionMode
onPressed: () => _openFile(track.filePath), ? null
icon: Icon(Icons.play_arrow, color: colorScheme.primary), : IconButton(
style: IconButton.styleFrom( onPressed: () => _openFile(track.filePath),
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), icon: Icon(Icons.play_arrow, color: colorScheme.primary),
), style: IconButton.styleFrom(
), backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
), ),
), ),
); );
} }
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) { Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length; final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh, color: colorScheme.surfaceContainerHigh,
@@ -697,12 +818,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount), context.l10n.downloadedAlbumSelectedCount(
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
Text( Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect, allSelected
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
), ),
], ],
), ),
@@ -715,9 +842,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_selectAll(tracks); _selectAll(tracks);
} }
}, },
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20), icon: Icon(
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), allSelected ? Icons.deselect : Icons.select_all,
style: TextButton.styleFrom(foregroundColor: colorScheme.primary), size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
), ),
], ],
), ),
@@ -725,18 +861,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
label: Text( label: Text(
selectedCount > 0 selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount) ? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete, : context.l10n.downloadedAlbumSelectToDelete,
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, backgroundColor: selectedCount > 0
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, ? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
), ),
), ),
-413
View File
@@ -1,413 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
final _urlController = TextEditingController();
int _currentIndex = 0;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null) {
_urlController.text = data!.text!;
}
}
Future<void> _fetchMetadata() async {
final url = _urlController.text.trim();
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(url, 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')),
);
}
void _downloadAll() {
final trackState = ref.read(trackProvider);
if (trackState.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
trackState.tracks,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')),
);
}
void _onNavTap(int index) {
setState(() => _currentIndex = index);
switch (index) {
case 0:
break;
case 1:
context.push('/queue');
break;
case 2:
context.push('/history');
break;
}
}
@override
Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider);
final queuedCount =
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold(
appBar: AppBar(
leading: Padding(
padding: const EdgeInsets.all(8.0),
child: CircleAvatar(
backgroundColor: colorScheme.primaryContainer,
child: Icon(Icons.music_note, color: colorScheme.onPrimaryContainer, size: 20),
),
),
title: const Text('SpotiFLAC'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _urlController,
decoration: InputDecoration(
hintText: 'Paste Spotify URL or search...',
prefixIcon: const Icon(Icons.link),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
],
),
),
onSubmitted: (_) => _fetchMetadata(),
),
),
if (trackState.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
trackState.error!,
style: TextStyle(color: colorScheme.error),
),
),
if (trackState.isLoading)
LinearProgressIndicator(color: colorScheme.primary),
if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme),
if (tracks.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: FilledButton.icon(
onPressed: _downloadAll,
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
),
Expanded(
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
destinations: [
const NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Badge(
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music_outlined),
),
selectedIcon: Badge(
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music),
),
label: 'Queue',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'History',
),
],
),
);
}
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (state.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: state.coverUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.albumName ?? state.playlistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${state.tracks.length} tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: const Icon(Icons.download),
),
],
),
),
);
}
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final isCollection = track.isCollection;
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
final capitalizedType = typeLabel.isNotEmpty
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
: 'Album';
final year = track.releaseDate != null && track.releaseDate!.length >= 4
? track.releaseDate!.substring(0, 4)
: '';
subtitleText = '$capitalizedType${track.artistName}${year.isNotEmpty ? '$year' : ''}';
} else {
subtitleText = track.artistName;
}
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isCollection ? Icons.album : Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: isCollection
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
);
}
Future<void> _openCollection(Track track) async {
final extensionId = track.source;
if (extensionId == null) return;
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
if (albumData != null && mounted) {
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
albumName: albumData['name'] as String? ?? track.name,
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
);
}
} else if (track.isPlaylistItem) {
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
if (playlistData != null && mounted) {
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
playlistName: playlistData['name'] as String? ?? track.name,
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $e')),
);
}
}
}
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
duration: (durationMs / 1000).round(),
releaseDate: data['release_date']?.toString(),
source: source,
);
}
String _formatDuration(int ms) {
if (ms == 0) return '';
final duration = Duration(milliseconds: ms);
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
Widget _buildEmptyState(ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Paste a Spotify URL to get started',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
+1668 -1119
View File
File diff suppressed because it is too large Load Diff
+307 -142
View File
@@ -1,11 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
/// Screen to display tracks from a local library album /// Screen to display tracks from a local library album
@@ -30,7 +30,6 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> { class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bool _isSelectionMode = false; bool _isSelectionMode = false;
final Set<String> _selectedIds = {}; final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache; late List<LocalLibraryItem> _sortedTracksCache;
@@ -43,13 +42,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_rebuildTrackCaches(); _rebuildTrackCaches();
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null) {
_dominantColor = cachedColor;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
} }
@override @override
@@ -59,13 +51,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
oldWidget.tracks.length != widget.tracks.length) { oldWidget.tracks.length != widget.tracks.length) {
_rebuildTrackCaches(); _rebuildTrackCaches();
} }
if (oldWidget.coverPath != widget.coverPath) {
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null && cachedColor != _dominantColor) {
_dominantColor = cachedColor;
}
_extractDominantColor();
}
} }
@override @override
@@ -82,18 +67,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} }
} }
Future<void> _extractDominantColor() async {
if (widget.coverPath == null || widget.coverPath!.isEmpty) return;
// Extract color from local file
final color = await PaletteService.instance.extractDominantColorFromFile(widget.coverPath!);
if (mounted && color != null && color != _dominantColor) {
setState(() {
_dominantColor = color;
});
}
}
List<LocalLibraryItem> _buildSortedTracks() { List<LocalLibraryItem> _buildSortedTracks() {
final tracks = List<LocalLibraryItem>.from(widget.tracks); final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) { tracks.sort((a, b) {
@@ -116,7 +89,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_hasMultipleDiscsCache = _discGroupsCache.length > 1; _hasMultipleDiscsCache = _discGroupsCache.length > 1;
} }
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) { Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
List<LocalLibraryItem> tracks,
) {
final discMap = <int, List<LocalLibraryItem>>{}; final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) { for (final track in tracks) {
final discNumber = track.discNumber ?? 1; final discNumber = track.discNumber ?? 1;
@@ -185,7 +160,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (confirmed == true && mounted) { if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier); final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList(); final idsToDelete = _selectedIds.toList();
int deletedCount = 0; int deletedCount = 0;
for (final id in idsToDelete) { for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull; final item = currentTracks.where((e) => e.id == id).firstOrNull;
@@ -197,14 +172,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
deletedCount++; deletedCount++;
} }
} }
_exitSelectionMode(); _exitSelectionMode();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))), SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
); );
// Go back if all tracks were deleted // Go back if all tracks were deleted
if (deletedCount == currentTracks.length) { if (deletedCount == currentTracks.length) {
Navigator.pop(context); Navigator.pop(context);
@@ -219,7 +196,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))), SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
); );
} }
} }
@@ -230,19 +209,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom; final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache; final tracks = _sortedTracksCache;
// Show empty state if no tracks found // Show empty state if no tracks found
if (tracks.isEmpty) { if (tracks.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(widget.albumName)),
title: Text(widget.albumName), body: const Center(child: Text('No tracks found for this album')),
),
body: const Center(
child: Text('No tracks found for this album'),
),
); );
} }
final validIds = tracks.map((t) => t.id).toSet(); final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id)); _selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) { if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -268,17 +243,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks), _buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
], ],
), ),
AnimatedPositioned( AnimatedPositioned(
duration: const Duration(milliseconds: 250), duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
left: 0, left: 0,
right: 0, right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding), child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
), ),
], ],
), ),
@@ -287,12 +269,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; final screenWidth = mediaSize.width;
final bgColor = _dominantColor ?? colorScheme.surface; final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -313,27 +300,49 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
collapseMode: CollapseMode.none, collapseMode: CollapseMode.none,
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background with dominant color // Blurred cover background
AnimatedContainer( if (widget.coverPath != null)
duration: const Duration(milliseconds: 500), Image.file(
decoration: BoxDecoration( File(widget.coverPath!),
gradient: LinearGradient( fit: BoxFit.cover,
begin: Alignment.topCenter, errorBuilder: (_, _, _) =>
end: Alignment.bottomCenter, Container(color: colorScheme.surface),
colors: [ )
bgColor, else
bgColor.withValues(alpha: 0.8), Container(color: colorScheme.surface),
colorScheme.surface, ClipRect(
], child: BackdropFilter(
stops: const [0.0, 0.6, 1.0], filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
Positioned(
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,
],
),
), ),
), ),
), ),
@@ -343,7 +352,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -366,13 +375,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
cacheWidth: (coverSize * 2).toInt(), cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) => errorBuilder: (context, error, stackTrace) =>
Container( Container(
color: colorScheme.surfaceContainerHighest, color:
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -381,14 +399,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -396,14 +420,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
); );
} }
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) { Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -411,40 +441,79 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [ children: [
Text( Text(
widget.albumName, widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface), style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
widget.artistName, widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant), style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
// "Local" badge // "Local" badge
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer), Icon(
Icons.folder,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
'Local',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Track count // Track count
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant), Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)), Text(
'${tracks.length} tracks',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
@@ -452,18 +521,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
// Quality badge if all tracks have the same quality // Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null) if (_getCommonQuality(tracks) != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24') color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer ? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest, : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text( child: Text(
_getCommonQuality(tracks)!, _getCommonQuality(tracks)!,
style: TextStyle( style: TextStyle(
color: _getCommonQuality(tracks)!.contains('24') color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.onPrimaryContainer ? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 12, fontSize: 12,
@@ -484,17 +556,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) return null; if (tracks.isEmpty) return null;
final first = tracks.first; final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null; if (first.bitDepth == null || first.sampleRate == null) return null;
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz'; final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
for (final track in tracks) { for (final track in tracks) {
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) { if (track.bitDepth != first.bitDepth ||
track.sampleRate != first.sampleRate) {
return null; return null;
} }
} }
return firstQuality; return firstQuality;
} }
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) { Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -502,14 +580,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [ children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary), Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), Text(
context.l10n.downloadedAlbumTracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(), const Spacer(),
if (!_isSelectionMode) if (!_isSelectionMode)
TextButton.icon( TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null, onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18), icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect), label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact), style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
), ),
], ],
), ),
@@ -517,15 +605,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 discGroups = _discGroupsCache;
final hasMultipleDiscs = _hasMultipleDiscsCache; final hasMultipleDiscs = _hasMultipleDiscsCache;
final slivers = <Widget>[]; final slivers = <Widget>[];
for (final discNumber in _sortedDiscNumbersCache) { for (final discNumber in _sortedDiscNumbersCache) {
final discTracks = discGroups[discNumber]!; final discTracks = discGroups[discNumber]!;
if (hasMultipleDiscs) { if (hasMultipleDiscs) {
slivers.add( slivers.add(
SliverToBoxAdapter( SliverToBoxAdapter(
@@ -534,7 +626,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.secondaryContainer, color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -542,14 +637,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer), Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
context.l10n.downloadedAlbumDiscHeader(discNumber), context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: Theme.of(context).textTheme.labelLarge
color: colorScheme.onSecondaryContainer, ?.copyWith(
fontWeight: FontWeight.w600, color: colorScheme.onSecondaryContainer,
), fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@@ -567,35 +667,46 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
); );
} }
slivers.add( slivers.add(
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]), (context, index) =>
_buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length, childCount: discTracks.length,
), ),
), ),
); );
} }
return SliverMainAxisGroup(slivers: slivers); return SliverMainAxisGroup(slivers: slivers);
} }
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) { Widget _buildTrackItem(
BuildContext context,
ColorScheme colorScheme,
LocalLibraryItem track,
) {
final isSelected = _selectedIds.contains(track.id); final isSelected = _selectedIds.contains(track.id);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent, color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
onTap: _isSelectionMode borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _openFile(track.filePath), : () => _openFile(track.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -604,12 +715,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
width: 24, width: 24,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent, color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2), border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
), ),
child: isSelected child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) ? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null, : null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -631,7 +753,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
track.trackName, track.trackName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
), ),
subtitle: Row( subtitle: Row(
children: [ children: [
@@ -644,27 +768,45 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
), ),
if (track.format != null) ...[ if (track.format != null) ...[
Text('', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)), Text(
'',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text( Text(
track.format!.toUpperCase(), track.format!.toUpperCase(),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12), style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
), ),
], ],
], ],
), ),
trailing: _isSelectionMode ? null : IconButton( trailing: _isSelectionMode
onPressed: () => _openFile(track.filePath), ? null
icon: Icon(Icons.play_arrow, color: colorScheme.primary), : IconButton(
style: IconButton.styleFrom( onPressed: () => _openFile(track.filePath),
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), icon: Icon(Icons.play_arrow, color: colorScheme.primary),
), style: IconButton.styleFrom(
), backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
), ),
), ),
); );
} }
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks, double bottomPadding) { Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length; final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
@@ -711,12 +853,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount), context.l10n.downloadedAlbumSelectedCount(
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
Text( Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect, allSelected
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
), ),
], ],
), ),
@@ -729,9 +877,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_selectAll(tracks); _selectAll(tracks);
} }
}, },
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20), icon: Icon(
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), allSelected ? Icons.deselect : Icons.select_all,
style: TextButton.styleFrom(foregroundColor: colorScheme.primary), size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
), ),
], ],
), ),
@@ -739,18 +896,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
label: Text( label: Text(
selectedCount > 0 selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount) ? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete, : context.l10n.downloadedAlbumSelectToDelete,
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, backgroundColor: selectedCount > 0
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, ? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
), ),
), ),
+47 -12
View File
@@ -82,9 +82,9 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
} }
} }
if (!mounted) return; if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst); Navigator.of(context).popUntil((route) => route.isFirst);
if (_currentIndex != 0) { if (_currentIndex != 0) {
@@ -181,10 +181,14 @@ class _MainShellState extends ConsumerState<MainShell> {
final treeUri = result['tree_uri'] as String? ?? ''; final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? ''; final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) { if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setDownloadTreeUri( ref
treeUri, .read(settingsProvider.notifier)
displayName: displayName.isNotEmpty ? displayName : treeUri, .setDownloadTreeUri(
); treeUri,
displayName: displayName.isNotEmpty
? displayName
: treeUri,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -280,7 +284,16 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch( final queueState = ref.watch(
downloadQueueProvider.select((s) => s.queuedCount), downloadQueueProvider.select((s) => s.queuedCount),
); );
final trackState = ref.watch(trackProvider); final trackHasSearchText = ref.watch(
trackProvider.select((s) => s.hasSearchText),
);
final trackHasContent = ref.watch(
trackProvider.select((s) => s.hasContent),
);
final trackIsLoading = ref.watch(trackProvider.select((s) => s.isLoading));
final trackIsShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess),
);
final showStore = ref.watch( final showStore = ref.watch(
settingsProvider.select((s) => s.showExtensionStore), settingsProvider.select((s) => s.showExtensionStore),
); );
@@ -292,10 +305,10 @@ class _MainShellState extends ConsumerState<MainShell> {
final canPop = final canPop =
_currentIndex == 0 && _currentIndex == 0 &&
!trackState.hasSearchText && !trackHasSearchText &&
!trackState.hasContent && !trackHasContent &&
!trackState.isLoading && !trackIsLoading &&
!trackState.isShowingRecentAccess && !trackIsShowingRecentAccess &&
!isKeyboardVisible; !isKeyboardVisible;
final tabs = <Widget>[ final tabs = <Widget>[
@@ -377,7 +390,9 @@ class _MainShellState extends ConsumerState<MainShell> {
body: PageView( body: PageView(
controller: _pageController, controller: _pageController,
onPageChanged: _onPageChanged, onPageChanged: _onPageChanged,
physics: const ClampingScrollPhysics(), physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
? const _NoSwipeRightPhysics()
: const ClampingScrollPhysics(),
children: tabs, children: tabs,
), ),
bottomNavigationBar: NavigationBar( bottomNavigationBar: NavigationBar(
@@ -400,6 +415,26 @@ class _MainShellState extends ConsumerState<MainShell> {
} }
} }
/// Custom physics that blocks swiping to the right (next page) while
/// still allowing vertical scrolling inside the page content.
class _NoSwipeRightPhysics extends ScrollPhysics {
const _NoSwipeRightPhysics({super.parent});
@override
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
// In a horizontal PageView, a negative offset means the user is
// dragging left (i.e. trying to go to the next page / right).
// Block that direction only.
if (offset < 0) return 0.0;
return super.applyPhysicsToUserOffset(position, offset);
}
}
class BouncingIcon extends StatefulWidget { class BouncingIcon extends StatefulWidget {
final Widget child; final Widget child;
const BouncingIcon({super.key, required this.child}); const BouncingIcon({super.key, required this.child});
+390 -132
View File
@@ -1,7 +1,8 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
@@ -32,7 +33,6 @@ class PlaylistScreen extends ConsumerStatefulWidget {
} }
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> { class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor;
bool _showTitleInAppBar = false; bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
List<Track>? _fetchedTracks; List<Track>? _fetchedTracks;
@@ -45,7 +45,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
_extractDominantColor();
_fetchTracksIfNeeded(); _fetchTracksIfNeeded();
} }
@@ -58,26 +57,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Future<void> _fetchTracksIfNeeded() async { Future<void> _fetchTracksIfNeeded() async {
if (widget.tracks.isNotEmpty || widget.playlistId == null) return; if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_error = null; _error = null;
}); });
try { try {
// Extract numeric ID from "deezer:123" format // Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!; String playlistId = widget.playlistId!;
if (playlistId.startsWith('deezer:')) { if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7); playlistId = playlistId.substring(7);
} }
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId); final result = await PlatformBridge.getDeezerMetadata(
'playlist',
playlistId,
);
if (!mounted) return; if (!mounted) return;
// Go backend returns 'track_list' not 'tracks' // Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? []; final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList(); final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
setState(() { setState(() {
_fetchedTracks = tracks; _fetchedTracks = tracks;
_isLoading = false; _isLoading = false;
@@ -99,7 +103,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else if (durationValue is double) { } else if (durationValue is double) {
durationMs = durationValue.toInt(); durationMs = durationValue.toInt();
} }
return Track( return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(), id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(), name: (data['name'] ?? '').toString(),
@@ -122,14 +126,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} }
} }
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
final color = await PaletteService.instance.extractDominantColor(widget.coverUrl);
if (mounted && color != null) {
setState(() => _dominantColor = color);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
@@ -149,15 +145,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; // 50% of screen width final screenWidth = mediaSize.width;
final bgColor = _dominantColor ?? colorScheme.surface; final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: AnimatedOpacity( title: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -175,27 +177,52 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
collapseMode: CollapseMode.none, collapseMode: CollapseMode.none,
background: Stack( background: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
// Background with dominant color // Blurred cover background
AnimatedContainer( if (widget.coverUrl != null)
duration: const Duration(milliseconds: 500), CachedNetworkImage(
decoration: BoxDecoration( imageUrl: widget.coverUrl!,
gradient: LinearGradient( fit: BoxFit.cover,
begin: Alignment.topCenter, cacheManager: CoverCacheManager.instance,
end: Alignment.bottomCenter, placeholder: (_, _) =>
colors: [ Container(color: colorScheme.surface),
bgColor, errorWidget: (_, _, _) =>
bgColor.withValues(alpha: 0.8), Container(color: colorScheme.surface),
colorScheme.surface, )
], else
stops: const [0.0, 0.6, 1.0], Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
Positioned(
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,
],
),
), ),
), ),
), ),
@@ -205,7 +232,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -222,15 +249,19 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant), child: Icon(
Icons.playlist_play,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
@@ -239,17 +270,20 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
), ),
], ],
), ),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground], stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
); );
}, },
), ),
leading: IconButton( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8), color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -264,34 +298,63 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)), Text(
widget.playlistName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer), Icon(
Icons.playlist_play,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.tracksCount(_tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)), Text(
context.l10n.tracksCount(_tracks.length),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
], ],
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
onPressed: _tracks.isEmpty ? null : () => _downloadAll(context), onPressed: _tracks.isEmpty
? null
: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18), icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(_tracks.length)), label: Text(context.l10n.downloadAllCount(_tracks.length)),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48), minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
), ),
), ),
], ],
@@ -310,7 +373,13 @@ const SizedBox(height: 16),
children: [ children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary), Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)), Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
], ],
), ),
), ),
@@ -326,7 +395,7 @@ const SizedBox(height: 16),
), ),
); );
} }
if (_error != null) { if (_error != null) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -339,7 +408,12 @@ const SizedBox(height: 16),
children: [ children: [
Icon(Icons.error_outline, color: colorScheme.error), Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))), Expanded(
child: Text(
_error!,
style: TextStyle(color: colorScheme.error),
),
),
], ],
), ),
), ),
@@ -347,7 +421,7 @@ const SizedBox(height: 16),
), ),
); );
} }
if (_tracks.isEmpty) { if (_tracks.isEmpty) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -361,21 +435,18 @@ const SizedBox(height: 16),
), ),
); );
} }
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { final track = _tracks[index];
final track = _tracks[index]; return KeyedSubtree(
return KeyedSubtree( key: ValueKey(track.id),
key: ValueKey(track.id), child: _PlaylistTrackItem(
child: _PlaylistTrackItem( track: track,
track: track, onDownload: () => _downloadTrack(context, track),
onDownload: () => _downloadTrack(context, track), ),
), );
); }, childCount: _tracks.length),
},
childCount: _tracks.length,
),
); );
} }
@@ -388,13 +459,23 @@ const SizedBox(height: 16),
artistName: track.artistName, artistName: track.artistName,
coverUrl: track.coverUrl, coverUrl: track.coverUrl,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); .read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); .read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
} }
} }
@@ -407,13 +488,29 @@ const SizedBox(height: 16),
trackName: '${_tracks.length} tracks', trackName: '${_tracks.length} tracks',
artistName: widget.playlistName, artistName: widget.playlistName,
onSelect: (quality, service) { onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length)))); .read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
),
);
}, },
); );
} else { } else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService); ref
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length)))); .read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
),
);
} }
} }
} }
@@ -428,34 +525,45 @@ class _PlaylistTrackItem extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch( final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]), downloadQueueLookupProvider.select(
(lookup) => lookup.byTrackId[track.id],
),
); );
final isInHistory = ref.watch(downloadHistoryProvider.select((state) { final isInHistory = ref.watch(
return state.isDownloaded(track.id); downloadHistoryProvider.select((state) {
})); return state.isDownloaded(track.id);
}),
);
// Check local library for duplicate detection // Check local library for duplicate detection
final settings = ref.watch(settingsProvider); final showLocalLibraryIndicator = ref.watch(
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates; settingsProvider.select(
final isInLocalLibrary = showLocalLibraryIndicator (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
? ref.watch(localLibraryProvider.select((state) => ),
state.existsInLibrary( );
isrc: track.isrc, final isInLocalLibrary = showLocalLibraryIndicator
trackName: track.name, ? ref.watch(
artistName: track.artistName, localLibraryProvider.select(
))) (state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: false; : false;
final isQueued = queueItem != null; final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading; final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing; final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed; final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0; final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory); final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -464,18 +572,58 @@ class _PlaylistTrackItem extends ConsumerWidget {
color: Colors.transparent, color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
leading: track.coverUrl != null borderRadius: BorderRadius.circular(12),
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance)) ),
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)), leading: track.coverUrl != null
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)), ? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row( subtitle: Row(
children: [ children: [
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))), Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary) ...[ if (isInLocalLibrary) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.tertiaryContainer, color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
@@ -483,51 +631,102 @@ leading: track.coverUrl != null
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer), Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3), const SizedBox(width: 3),
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)), Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
], ],
), ),
), ),
], ],
], ],
), ),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress), trailing: _buildDownloadButton(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), context,
ref,
colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
), ),
), ),
); );
} }
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async { void _handleTap(
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return; if (isQueued) return;
if (isInLocalLibrary) { if (isInLocalLibrary) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
} }
return; return;
} }
if (isInHistory) { if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id); final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) { if (historyItem != null) {
final exists = await fileExists(historyItem.filePath); final exists = await fileExists(historyItem.filePath);
if (exists) { if (exists) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name)))); ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
} }
return; return;
} else { } else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id); ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
} }
} }
} }
onDownload(); onDownload();
} }
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, { Widget _buildDownloadButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued, required bool isQueued,
required bool isDownloading, required bool isDownloading,
required bool isFinalizing, required bool isFinalizing,
@@ -538,11 +737,29 @@ leading: track.coverUrl != null
}) { }) {
const double size = 44.0; const double size = 44.0;
const double iconSize = 20.0; const double iconSize = 20.0;
if (showAsDownloaded) { if (showAsDownloaded) {
return GestureDetector( return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary), onTap: () => _handleTap(
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)), context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
); );
} else if (isFinalizing) { } else if (isFinalizing) {
return SizedBox( return SizedBox(
@@ -551,7 +768,11 @@ leading: track.coverUrl != null
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16), Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
], ],
), ),
@@ -563,17 +784,54 @@ leading: track.coverUrl != null
child: Stack( child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest), CircularProgressIndicator(
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)), value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
], ],
), ),
); );
} else if (isQueued) { } else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize)); return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else { } else {
return GestureDetector( return GestureDetector(
onTap: onDownload, onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)), child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
); );
} }
} }
-248
View File
@@ -1,248 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
class QueueScreen extends ConsumerWidget {
const QueueScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.queueTitle),
actions: [
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: context.l10n.queueClearCompleted,
),
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
tooltip: context.l10n.queueClearAll,
),
],
),
body: items.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) =>
_buildQueueItem(context, ref, items[index], colorScheme),
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
context.l10n.queueEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
context.l10n.queueEmptySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
return ListTile(
leading: item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
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(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
],
],
),
trailing: _buildStatusIcon(context, item, colorScheme),
onTap: item.status == DownloadStatus.queued
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
: null,
);
}
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
switch (item.status) {
case DownloadStatus.queued:
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
case DownloadStatus.downloading:
return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: item.progress,
strokeWidth: 2,
color: colorScheme.primary,
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 24,
height: 24,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
],
),
);
case DownloadStatus.completed:
return Icon(Icons.check_circle, color: colorScheme.primary);
case DownloadStatus.failed:
return IconButton(
icon: Icon(Icons.error, color: colorScheme.error),
onPressed: () => _showErrorDialog(context, item, colorScheme),
tooltip: 'Tap to see error details',
);
case DownloadStatus.skipped:
return Icon(Icons.skip_next, color: colorScheme.primary);
}
}
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
Text(context.l10n.queueDownloadFailed),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
const SizedBox(height: 16),
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? context.l10n.queueUnknownError,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogClose),
),
],
),
);
}
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
+1180 -853
View File
File diff suppressed because it is too large Load Diff
+314 -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/services/cover_cache_manager.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget { class AboutPage extends StatelessWidget {
@@ -12,7 +13,7 @@ class AboutPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, canPop: true,
@@ -20,211 +21,229 @@ class AboutPage extends StatelessWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 120 + topPadding, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxHeight = 120 + topPadding; final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding; final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final expandRatio =
final leftPadding = 56 - (32 * expandRatio); ((constraints.maxHeight - minHeight) /
return FlexibleSpaceBar( (maxHeight - minHeight))
expandedTitleScale: 1.0, .clamp(0.0, 1.0);
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), final leftPadding = 56 - (32 * expandRatio);
title: Text( return FlexibleSpaceBar(
context.l10n.aboutTitle, expandedTitleScale: 1.0,
style: TextStyle( titlePadding: EdgeInsets.only(
fontSize: 20 + (8 * expandRatio), // 20 -> 28 left: leftPadding,
fontWeight: FontWeight.bold, bottom: 16,
color: colorScheme.onSurface,
), ),
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: _AppHeaderCard(),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutContributors,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
), ),
); _ContributorItem(
}, name: AppInfo.originalAuthor,
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
],
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: SettingsSectionHeader(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), title: context.l10n.aboutTranslators,
child: _AppHeaderCard(), ),
), ),
), const SliverToBoxAdapter(child: _TranslatorsSection()),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors), child: SettingsSectionHeader(
), title: context.l10n.aboutSpecialThanks,
SliverToBoxAdapter( ),
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
_ContributorItem(
name: AppInfo.originalAuthor,
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
],
), ),
), SliverToBoxAdapter(
child: SettingsGroup(
SliverToBoxAdapter( children: [
child: SettingsSectionHeader(title: context.l10n.aboutTranslators), _ContributorItem(
), name: 'binimum',
const SliverToBoxAdapter( description: context.l10n.aboutBinimumDesc,
child: _TranslatorsSection(), githubUsername: 'binimum',
), showDivider: true,
),
SliverToBoxAdapter( _ContributorItem(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks), name: 'sachinsenal0x64',
), description: context.l10n.aboutSachinsenalDesc,
SliverToBoxAdapter( githubUsername: 'sachinsenal0x64',
child: SettingsGroup( showDivider: true,
children: [ ),
_ContributorItem( _ContributorItem(
name: 'binimum', name: 'sjdonado',
description: context.l10n.aboutBinimumDesc, description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'binimum', githubUsername: 'sjdonado',
showDivider: true, showDivider: true,
), ),
_ContributorItem( _AboutSettingsItem(
name: 'sachinsenal0x64', icon: Icons.music_note_outlined,
description: context.l10n.aboutSachinsenalDesc, title: context.l10n.aboutDabMusic,
githubUsername: 'sachinsenal0x64', subtitle: context.l10n.aboutDabMusicDesc,
showDivider: true, onTap: () => _launchUrl('https://dabmusic.xyz'),
), showDivider: true,
_ContributorItem( ),
name: 'sjdonado', _AboutSettingsItem(
description: context.l10n.aboutSjdonadoDesc, icon: Icons.music_note_outlined,
githubUsername: 'sjdonado', title: context.l10n.aboutSpotiSaver,
showDivider: true, subtitle: context.l10n.aboutSpotiSaverDesc,
), onTap: () => _launchUrl('https://spotisaver.net'),
_AboutSettingsItem( showDivider: false,
icon: Icons.music_note_outlined, ),
title: context.l10n.aboutDabMusic, ],
subtitle: context.l10n.aboutDabMusicDesc, ),
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: false,
),
],
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks), child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.phone_android,
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.computer,
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
), ),
), SliverToBoxAdapter(
child: SettingsGroup(
SliverToBoxAdapter( children: [
child: SettingsSectionHeader(title: context.l10n.aboutSocial), _AboutSettingsItem(
), icon: Icons.phone_android,
SliverToBoxAdapter( title: context.l10n.aboutMobileSource,
child: SettingsGroup( subtitle: 'github.com/${AppInfo.githubRepo}',
children: [ onTap: () => _launchUrl(AppInfo.githubUrl),
_AboutSettingsItem( showDivider: true,
icon: Icons.telegram, ),
title: context.l10n.aboutTelegramChannel, _AboutSettingsItem(
subtitle: context.l10n.aboutTelegramChannelSubtitle, icon: Icons.computer,
onTap: () => _launchUrl('https://t.me/spotiflac'), title: context.l10n.aboutPCSource,
showDivider: true, subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
), onTap: () => _launchUrl(AppInfo.originalGithubUrl),
_AboutSettingsItem( showDivider: true,
icon: Icons.forum_outlined, ),
title: context.l10n.aboutTelegramChat, _AboutSettingsItem(
subtitle: context.l10n.aboutTelegramChatSubtitle, icon: Icons.bug_report_outlined,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'), title: context.l10n.aboutReportIssue,
showDivider: false, subtitle: context.l10n.aboutReportIssueSubtitle,
), onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
], showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp), child: SettingsSectionHeader(title: context.l10n.aboutSocial),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
_AboutSettingsItem( _AboutSettingsItem(
icon: Icons.info_outline, icon: Icons.telegram,
title: context.l10n.aboutVersion, title: context.l10n.aboutTelegramChannel,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})', subtitle: context.l10n.aboutTelegramChannelSubtitle,
showDivider: false, onTap: () => _launchUrl('https://t.me/spotiflac'),
), showDivider: true,
], ),
_AboutSettingsItem(
icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'),
showDivider: false,
),
],
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: SettingsSectionHeader(title: context.l10n.aboutApp),
padding: const EdgeInsets.all(24), ),
child: Center( SliverToBoxAdapter(
child: Text( child: SettingsGroup(
AppInfo.copyright, children: [
style: Theme.of(context).textTheme.bodySmall?.copyWith( _AboutSettingsItem(
color: colorScheme.onSurfaceVariant, icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle:
'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
), ),
), ),
), ),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)), const SliverToBoxAdapter(child: SizedBox(height: 16)),
], ],
),
), ),
),
); );
} }
@@ -239,73 +258,93 @@ class _AppHeaderCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest; : colorScheme.surfaceContainerHighest;
return Container( return LayoutBuilder(
decoration: BoxDecoration( builder: (context, constraints) {
color: cardColor, final cardWidth = constraints.maxWidth;
borderRadius: BorderRadius.circular(20), final shortestSide = MediaQuery.sizeOf(context).shortestSide;
), final textScale = MediaQuery.textScalerOf(
padding: const EdgeInsets.all(24), context,
child: Column( ).scale(1.0).clamp(1.0, 1.4);
children: [ final logoSize = (shortestSide * 0.22).clamp(72.0, 88.0);
Container( final contentPadding = (cardWidth * 0.06).clamp(16.0, 24.0);
width: 88, final titleGap = (16 * (1 + ((textScale - 1) * 0.2))).clamp(12.0, 20.0);
height: 88,
decoration: BoxDecoration( return Container(
color: colorScheme.primary, decoration: BoxDecoration(
shape: BoxShape.circle, color: cardColor,
), borderRadius: BorderRadius.circular(20),
child: Image.asset( ),
'assets/images/logo-transparant.png', padding: EdgeInsets.all(contentPadding),
color: colorScheme.onPrimary, child: Column(
fit: BoxFit.contain, children: [
errorBuilder: (_, _, _) => ClipRRect( Container(
borderRadius: BorderRadius.circular(24), width: logoSize,
height: logoSize,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset( child: Image.asset(
'assets/images/logo.png', 'assets/images/logo-transparant.png',
width: 88, color: colorScheme.onPrimary,
height: 88, fit: BoxFit.contain,
fit: BoxFit.cover, errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: logoSize,
height: logoSize,
fit: BoxFit.cover,
),
),
), ),
), ),
), SizedBox(height: titleGap),
), Text(
const SizedBox(height: 16), AppInfo.appName,
Text( textAlign: TextAlign.center,
AppInfo.appName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(
style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, ),
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
), ),
), const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(height: titleGap),
Text(
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
), ),
const SizedBox(height: 16), );
Text( },
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
); );
} }
} }
@@ -326,7 +365,7 @@ class _ContributorItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -340,7 +379,7 @@ class _ContributorItem extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: 'https://github.com/$githubUsername.png', imageUrl: 'https://github.com/$githubUsername.png',
width: 40, width: 40,
height: 40, height: 40,
@@ -373,10 +412,7 @@ child: CachedNetworkImage(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(name, style: Theme.of(context).textTheme.bodyLarge),
name,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
description, description,
@@ -478,9 +514,12 @@ class _TranslatorsSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest; : colorScheme.surfaceContainerHighest;
return Padding( return Padding(
@@ -494,9 +533,9 @@ class _TranslatorsSection extends StatelessWidget {
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: _translators.map((translator) => _TranslatorChip( children: _translators
translator: translator, .map((translator) => _TranslatorChip(translator: translator))
)).toList(), .toList(),
), ),
), ),
); );
@@ -528,7 +567,9 @@ class _TranslatorChip extends StatelessWidget {
radius: 10, radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2), backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text( child: Text(
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?', translator.name.isNotEmpty
? translator.name[0].toUpperCase()
: '?',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -545,10 +586,7 @@ class _TranslatorChip extends StatelessWidget {
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(translator.flag, style: const TextStyle(fontSize: 14)),
translator.flag,
style: const TextStyle(fontSize: 14),
),
], ],
), ),
), ),
@@ -580,7 +618,7 @@ class _AboutSettingsItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -595,31 +633,34 @@ class _AboutSettingsItem extends StatelessWidget {
SizedBox( SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), child: Icon(
icon,
color: colorScheme.onSurfaceVariant,
size: 24,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(title, style: Theme.of(context).textTheme.bodyLarge),
title,
style: Theme.of(context).textTheme.bodyLarge,
),
if (subtitle != null) ...[ if (subtitle != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
subtitle!, subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
color: colorScheme.onSurfaceVariant, ?.copyWith(color: colorScheme.onSurfaceVariant),
),
), ),
], ],
], ],
), ),
), ),
if (onTap != null) if (onTap != null)
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
], ],
), ),
), ),
+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/l10n/supported_locales.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart'; import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class AppearanceSettingsPage extends ConsumerWidget { class AppearanceSettingsPage extends ConsumerWidget {
@@ -14,7 +15,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
final themeSettings = ref.watch(themeProvider); final themeSettings = ref.watch(themeProvider);
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, canPop: true,
@@ -22,21 +23,21 @@ class AppearanceSettingsPage extends ConsumerWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 120 + topPadding, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
), ),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -77,8 +78,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) => onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color), ref.read(themeProvider.notifier).setSeedColor(color),
), ),
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme), child: SettingsSectionHeader(title: context.l10n.sectionTheme),
@@ -113,9 +114,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [ children: [
_LanguageSelector( _LanguageSelector(
currentLocale: settings.locale, currentLocale: settings.locale,
onChanged: (locale) => ref onChanged: (locale) =>
.read(settingsProvider.notifier) ref.read(settingsProvider.notifier).setLocale(locale),
.setLocale(locale),
), ),
], ],
), ),
@@ -156,151 +156,167 @@ class _ThemePreviewCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return RepaintBoundary( return RepaintBoundary(
child: Container( child: LayoutBuilder(
height: 200, builder: (context, constraints) {
width: double.infinity, final cardWidth = constraints.maxWidth;
decoration: BoxDecoration( final previewHeight = (cardWidth * 0.56).clamp(170.0, 220.0);
color: colorScheme final innerWidth = (cardWidth - 48).clamp(220.0, 320.0);
.surfaceContainerHighest, final innerHeight = (previewHeight * 0.70).clamp(120.0, 160.0);
borderRadius: BorderRadius.circular(28), final innerPadding = (innerHeight * 0.11).clamp(12.0, 18.0);
), final artworkSize = (innerHeight - (innerPadding * 2)).clamp(
clipBehavior: Clip.antiAlias, 80.0,
child: Stack( 120.0,
children: [ );
Positioned(
top: -50,
right: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
),
),
),
Center( return Container(
child: Container( constraints: BoxConstraints(minHeight: previewHeight),
width: 260, width: double.infinity,
height: 140, decoration: BoxDecoration(
padding: const EdgeInsets.all(16), color: colorScheme.surfaceContainerHighest,
decoration: BoxDecoration( borderRadius: BorderRadius.circular(28),
color: colorScheme.surface, ),
borderRadius: BorderRadius.circular(20), clipBehavior: Clip.antiAlias,
boxShadow: [ child: Stack(
BoxShadow( children: [
color: Colors.black.withValues(alpha: 0.1), Positioned(
blurRadius: 12, top: -(previewHeight * 0.25),
offset: const Offset(0, 8), right: -(previewHeight * 0.25),
), child: Container(
], width: previewHeight,
), height: previewHeight,
child: Row( decoration: BoxDecoration(
children: [ shape: BoxShape.circle,
Container( color: colorScheme.primaryContainer.withValues(
width: 108, alpha: 0.5,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
), ),
), ),
const SizedBox(width: 16), ),
),
Expanded( Positioned(
child: Column( bottom: -(previewHeight * 0.15),
crossAxisAlignment: CrossAxisAlignment.start, left: -(previewHeight * 0.15),
mainAxisAlignment: MainAxisAlignment.center, child: Container(
children: [ width: previewHeight * 0.75,
Container( height: previewHeight * 0.75,
width: double.infinity, decoration: BoxDecoration(
height: 14, shape: BoxShape.circle,
decoration: BoxDecoration( color: colorScheme.tertiaryContainer.withValues(
color: colorScheme.onSurface, alpha: 0.5,
borderRadius: BorderRadius.circular(4), ),
), ),
),
),
Center(
child: Container(
width: innerWidth,
height: innerHeight,
padding: EdgeInsets.all(innerPadding),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
width: artworkSize,
height: artworkSize,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
), ),
const SizedBox(height: 8), child: Icon(
Container( Icons.music_note,
width: 80, color: colorScheme.onPrimary,
height: 10, size: artworkSize * 0.44,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(height: 24), ),
Row( const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Container(
Icons.skip_previous, width: double.infinity,
size: 24, height: 14,
color: colorScheme.onSurfaceVariant, decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(width: 12), const SizedBox(height: 8),
Icon( Container(
Icons.play_circle_fill, width: 80,
size: 32, height: 10,
color: colorScheme.primary, decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(width: 12), const SizedBox(height: 24),
Icon( Row(
Icons.skip_next, children: [
size: 24, Icon(
color: colorScheme.onSurfaceVariant, Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
),
],
), ),
], ],
), ),
], ),
), ],
), ),
],
),
),
),
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
), ),
), ),
), Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark
? context.l10n.appearanceThemeDark
: context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
), ),
], );
), },
), ),
); );
} }
@@ -498,7 +514,7 @@ class _ThemeModeChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark final unselectedColor = isDark
? Color.alphaBlend( ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05), Colors.white.withValues(alpha: 0.05),
@@ -694,7 +710,7 @@ class _LanguageSelector extends StatelessWidget {
required this.onChanged, required this.onChanged,
}); });
static const _allLanguages = [ static const _allLanguages = [
('system', 'System Default', Icons.phone_android), ('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language), ('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language), ('id', 'Bahasa Indonesia', Icons.language),
@@ -735,16 +751,10 @@ static const _allLanguages = [
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return ListTile( return ListTile(
leading: Icon( leading: Icon(Icons.language, color: colorScheme.onSurfaceVariant),
Icons.language,
color: colorScheme.onSurfaceVariant,
),
title: Text(context.l10n.appearanceLanguage), title: Text(context.l10n.appearanceLanguage),
subtitle: Text(_getLanguageName(currentLocale)), subtitle: Text(_getLanguageName(currentLocale)),
trailing: Icon( trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
onTap: () => _showLanguagePicker(context), onTap: () => _showLanguagePicker(context),
); );
} }
@@ -765,9 +775,9 @@ static const _allLanguages = [
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text( child: Text(
context.l10n.appearanceLanguage, context.l10n.appearanceLanguage,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
@@ -781,22 +791,22 @@ static const _allLanguages = [
return ListTile( return ListTile(
leading: Icon( leading: Icon(
lang.$3, lang.$3,
color: isSelected color: isSelected
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
title: Text( title: Text(
lang.$2, lang.$2,
style: TextStyle( style: TextStyle(
color: isSelected color: isSelected
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurface, : colorScheme.onSurface,
fontWeight: isSelected fontWeight: isSelected
? FontWeight.w600 ? FontWeight.w600
: FontWeight.normal, : FontWeight.normal,
), ),
), ),
trailing: isSelected trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary) ? Icon(Icons.check, color: colorScheme.primary)
: null, : null,
onTap: () { onTap: () {
@@ -0,0 +1,675 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class CacheManagementPage extends ConsumerStatefulWidget {
const CacheManagementPage({super.key});
@override
ConsumerState<CacheManagementPage> createState() =>
_CacheManagementPageState();
}
class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
// Keep in sync with ExploreNotifier keys.
static const String _exploreCacheKey = 'explore_home_feed_cache';
static const String _exploreCacheTsKey = 'explore_home_feed_ts';
_CacheOverview? _overview;
bool _isLoading = true;
String? _busyAction;
@override
void initState() {
super.initState();
_refreshOverview();
}
bool get _isBusy => _busyAction != null;
Future<void> _refreshOverview() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final overview = await _buildOverview();
if (!mounted) return;
setState(() {
_overview = overview;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
Future<_CacheOverview> _buildOverview() async {
final appCacheDir = await getApplicationCacheDirectory();
final tempDir = await getTemporaryDirectory();
final appCachePath = p.normalize(appCacheDir.path);
final tempPath = p.normalize(tempDir.path);
final tempIsSameAsAppCache = appCachePath == tempPath;
final appCacheStats = await _scanDirectory(Directory(appCachePath));
final tempStats = tempIsSameAsAppCache
? null
: await _scanDirectory(Directory(tempPath));
final coverStats = await CoverCacheManager.getStats();
final prefs = await SharedPreferences.getInstance();
final explorePayload = prefs.getString(_exploreCacheKey);
final exploreTs = prefs.getInt(_exploreCacheTsKey);
var exploreBytes = 0;
if (explorePayload != null && explorePayload.isNotEmpty) {
exploreBytes += utf8.encode(explorePayload).length;
}
if (exploreTs != null) {
exploreBytes += 8;
}
final hasExploreCache = exploreBytes > 0;
int trackCacheEntries;
try {
trackCacheEntries = await PlatformBridge.getTrackCacheSize();
} catch (_) {
trackCacheEntries = 0;
}
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
final libraryCoverStats = await _scanDirectory(libraryCoverDir);
return _CacheOverview(
appCachePath: appCachePath,
appCacheStats: appCacheStats,
tempPath: tempIsSameAsAppCache ? null : tempPath,
tempStats: tempStats,
tempIsSameAsAppCache: tempIsSameAsAppCache,
coverStats: coverStats,
libraryCoverStats: libraryCoverStats,
exploreCacheBytes: exploreBytes,
hasExploreCache: hasExploreCache,
trackCacheEntries: trackCacheEntries,
);
}
Future<_DirectoryStats> _scanDirectory(Directory directory) async {
if (!await directory.exists()) {
return const _DirectoryStats(fileCount: 0, totalSizeBytes: 0);
}
var fileCount = 0;
var totalSize = 0;
try {
await for (final entity in directory.list(
recursive: true,
followLinks: false,
)) {
if (entity is File) {
fileCount++;
totalSize += await entity.length();
}
}
} catch (_) {}
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
}
Future<void> _clearDirectoryContents(String path) async {
final directory = Directory(path);
if (!await directory.exists()) return;
try {
final entities = directory.listSync(followLinks: false);
for (final entity in entities) {
try {
await entity.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
try {
await directory.create(recursive: true);
} catch (_) {}
}
Future<void> _clearAppCache() async {
final cacheDir = await getApplicationCacheDirectory();
await _clearDirectoryContents(cacheDir.path);
}
Future<void> _clearTempCache() async {
final tempDir = await getTemporaryDirectory();
await _clearDirectoryContents(tempDir.path);
}
Future<void> _clearCoverCache() async {
await CoverCacheManager.clearCache();
}
Future<void> _clearLibraryCoverCache() async {
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
await _clearDirectoryContents(libraryCoverDir.path);
}
Future<void> _clearExploreCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_exploreCacheKey);
await prefs.remove(_exploreCacheTsKey);
}
Future<void> _clearTrackCache() async {
await PlatformBridge.clearTrackCache();
}
Future<void> _clearAllCaches() async {
final currentOverview = _overview;
await _clearAppCache();
if (currentOverview != null && !currentOverview.tempIsSameAsAppCache) {
await _clearTempCache();
}
await _clearCoverCache();
await _clearLibraryCoverCache();
await _clearExploreCache();
await _clearTrackCache();
}
Future<bool> _confirmClear(String target) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.cacheClearConfirmTitle),
content: Text(context.l10n.cacheClearConfirmMessage(target)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogClear),
),
],
),
);
return confirm == true;
}
Future<bool> _confirmClearAll() async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.cacheClearAllConfirmTitle),
content: Text(context.l10n.cacheClearAllConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogClear),
),
],
),
);
return confirm == true;
}
Future<void> _runAction(
String actionKey,
Future<void> Function() action, {
String? successMessage,
}) async {
if (_isBusy || !mounted) return;
setState(() => _busyAction = actionKey);
try {
await action();
if (!mounted) return;
if (successMessage != null && successMessage.isNotEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(successMessage)));
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
if (mounted) {
setState(() => _busyAction = null);
await _refreshOverview();
}
}
}
Future<void> _confirmAndRunAction({
required String actionKey,
required String targetLabel,
required Future<void> Function() action,
}) async {
final confirmed = await _confirmClear(targetLabel);
if (!confirmed) return;
if (!mounted) return;
await _runAction(
actionKey,
action,
successMessage: context.l10n.cacheClearSuccess(targetLabel),
);
}
Future<void> _cleanupUnusedData() async {
await _runAction('cleanup_unused', () async {
final orphanedDownloads = await ref
.read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads();
final missingLibraryEntries = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.cacheCleanupResult(
orphanedDownloads,
missingLibraryEntries,
),
),
),
);
});
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
String _formatDirectorySize(_DirectoryStats stats) {
if (stats.fileCount == 0 || stats.totalSizeBytes == 0) {
return context.l10n.cacheNoData;
}
return context.l10n.cacheSizeWithFiles(
_formatBytes(stats.totalSizeBytes),
stats.fileCount,
);
}
String _buildSubtitle(String description, String sizeInfo) {
return '$description\n$sizeInfo';
}
Widget _buildClearTrailing(String actionKey, VoidCallback onPressed) {
if (_busyAction == actionKey) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
return TextButton(
onPressed: _isBusy ? null : onPressed,
child: Text(context.l10n.dialogClear),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final overview = _overview;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.cacheTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
if (_isLoading || overview == null)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else ...[
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.cacheSummaryTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 6),
Text(
context.l10n.cacheEstimatedTotal(
_formatBytes(overview.totalKnownDiskCacheBytes),
),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 2),
Text(
context.l10n.cacheSummarySubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.85,
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _isBusy
? null
: () async {
final l10n = context.l10n;
final confirmed = await _confirmClearAll();
if (!confirmed) return;
if (!mounted) return;
await _runAction(
'clear_all',
_clearAllCaches,
successMessage: l10n.cacheClearSuccess(
l10n.cacheClearAll,
),
);
},
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(context.l10n.cacheClearAll),
),
OutlinedButton.icon(
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
label: Text(context.l10n.cacheRefreshStats),
),
],
),
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.cacheSectionStorage,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.cacheAppDirectory,
subtitle: _buildSubtitle(
context.l10n.cacheAppDirectoryDesc,
_formatDirectorySize(overview.appCacheStats),
),
trailing: _buildClearTrailing(
'clear_app_cache',
() => _confirmAndRunAction(
actionKey: 'clear_app_cache',
targetLabel: context.l10n.cacheAppDirectory,
action: _clearAppCache,
),
),
),
if (!overview.tempIsSameAsAppCache &&
overview.tempStats != null)
SettingsItem(
icon: Icons.timer_outlined,
title: context.l10n.cacheTempDirectory,
subtitle: _buildSubtitle(
context.l10n.cacheTempDirectoryDesc,
_formatDirectorySize(overview.tempStats!),
),
trailing: _buildClearTrailing(
'clear_temp_cache',
() => _confirmAndRunAction(
actionKey: 'clear_temp_cache',
targetLabel: context.l10n.cacheTempDirectory,
action: _clearTempCache,
),
),
),
SettingsItem(
icon: Icons.image_outlined,
title: context.l10n.cacheCoverImage,
subtitle: _buildSubtitle(
context.l10n.cacheCoverImageDesc,
overview.coverStats.fileCount > 0 &&
overview.coverStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
_formatBytes(overview.coverStats.totalSizeBytes),
overview.coverStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_cover_cache',
() => _confirmAndRunAction(
actionKey: 'clear_cover_cache',
targetLabel: context.l10n.cacheCoverImage,
action: _clearCoverCache,
),
),
),
SettingsItem(
icon: Icons.library_music_outlined,
title: context.l10n.cacheLibraryCover,
subtitle: _buildSubtitle(
context.l10n.cacheLibraryCoverDesc,
overview.libraryCoverStats.fileCount > 0 &&
overview.libraryCoverStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
_formatBytes(
overview.libraryCoverStats.totalSizeBytes,
),
overview.libraryCoverStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_library_cover_cache',
() => _confirmAndRunAction(
actionKey: 'clear_library_cover_cache',
targetLabel: context.l10n.cacheLibraryCover,
action: _clearLibraryCoverCache,
),
),
),
SettingsItem(
icon: Icons.explore_outlined,
title: context.l10n.cacheExploreFeed,
subtitle: _buildSubtitle(
context.l10n.cacheExploreFeedDesc,
overview.hasExploreCache
? context.l10n.cacheSizeOnly(
_formatBytes(overview.exploreCacheBytes),
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_explore_cache',
() => _confirmAndRunAction(
actionKey: 'clear_explore_cache',
targetLabel: context.l10n.cacheExploreFeed,
action: _clearExploreCache,
),
),
),
SettingsItem(
icon: Icons.memory_outlined,
title: context.l10n.cacheTrackLookup,
subtitle: _buildSubtitle(
context.l10n.cacheTrackLookupDesc,
overview.trackCacheEntries > 0
? context.l10n.cacheEntries(overview.trackCacheEntries)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_track_cache',
() => _confirmAndRunAction(
actionKey: 'clear_track_cache',
targetLabel: context.l10n.cacheTrackLookup,
action: _clearTrackCache,
),
),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.cacheSectionMaintenance,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.cacheCleanupUnused,
subtitle: '${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
trailing: _buildClearTrailing(
'cleanup_unused',
_cleanupUnusedData,
),
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
],
),
);
}
}
class _CacheOverview {
final String appCachePath;
final _DirectoryStats appCacheStats;
final String? tempPath;
final _DirectoryStats? tempStats;
final bool tempIsSameAsAppCache;
final CacheStats coverStats;
final _DirectoryStats libraryCoverStats;
final int exploreCacheBytes;
final bool hasExploreCache;
final int trackCacheEntries;
const _CacheOverview({
required this.appCachePath,
required this.appCacheStats,
this.tempPath,
this.tempStats,
required this.tempIsSameAsAppCache,
required this.coverStats,
required this.libraryCoverStats,
required this.exploreCacheBytes,
required this.hasExploreCache,
required this.trackCacheEntries,
});
int get totalKnownDiskCacheBytes {
return appCacheStats.totalSizeBytes +
(tempStats?.totalSizeBytes ?? 0) +
coverStats.totalSizeBytes +
libraryCoverStats.totalSizeBytes +
exploreCacheBytes;
}
}
class _DirectoryStats {
final int fileCount;
final int totalSizeBytes;
const _DirectoryStats({
required this.fileCount,
required this.totalSizeBytes,
});
}
+97 -78
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/donate_icons.dart'; import 'package:spotiflac_android/widgets/donate_icons.dart';
class DonatePage extends StatelessWidget { class DonatePage extends StatelessWidget {
@@ -9,7 +10,7 @@ class DonatePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
@@ -54,47 +55,6 @@ class DonatePage extends StatelessWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
// Header message
Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Icon(
Icons.favorite_rounded,
size: 48,
color: colorScheme.primary,
),
const SizedBox(height: 12),
Text(
'Support SpotiFLAC-Mobile',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
'SpotiFLAC-Mobile is free and open source. '
'If you enjoy using it, consider supporting '
'the development.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
),
const SizedBox(height: 16),
// Donate links card // Donate links card
_DonateLinksCard(colorScheme: colorScheme), _DonateLinksCard(colorScheme: colorScheme),
@@ -103,57 +63,83 @@ class DonatePage extends StatelessWidget {
// Recent donors section // Recent donors section
_RecentDonorsCard(colorScheme: colorScheme), _RecentDonorsCard(colorScheme: colorScheme),
const SizedBox(height: 12), const SizedBox(height: 16),
// Notice // Combined notice card
Card( Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Row(
Icons.info_outline_rounded, children: [
size: 20, Icon(
color: colorScheme.onSurfaceVariant, Icons.volunteer_activism_rounded,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Good to Know',
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
), ),
const SizedBox(width: 12), const SizedBox(height: 10),
Expanded( _NoticeLine(
child: Column( icon: Icons.block,
crossAxisAlignment: CrossAxisAlignment.start, text: 'Not selling early access, premium features, or paywalls',
children: [ colorScheme: colorScheme,
Text( ),
'About Supporters', const SizedBox(height: 6),
style: Theme.of(context).textTheme.titleSmall _NoticeLine(
?.copyWith( icon: Icons.build_outlined,
fontWeight: FontWeight.w600, text: 'Funds go to dev tools & testing devices',
color: colorScheme.onSurface, colorScheme: colorScheme,
), ),
), const SizedBox(height: 6),
const SizedBox(height: 6), _NoticeLine(
Text( icon: Icons.favorite_border,
'By supporting SpotiFLAC, you become part of this app\'s history. ' text: 'Your support is the only way to keep this project alive',
'Your name will remain in this version permanently as a token of appreciation. ' colorScheme: colorScheme,
'The supporter list is updated manually each month and embedded directly in the app ' ),
'-- no remote server is used. Even if your support period ends, your name stays in ' Divider(
'every version it was included in.', height: 24,
style: Theme.of(context).textTheme.bodySmall color: colorScheme.outlineVariant.withValues(alpha: 0.3),
?.copyWith( ),
color: colorScheme.onSurfaceVariant, _NoticeLine(
), icon: Icons.history,
), text: 'Your name stays permanently in every version it was included in',
], colorScheme: colorScheme,
), ),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.update,
text: 'Supporter list is updated monthly and embedded in the app',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.cloud_off,
text: 'No remote server -- everything is stored locally',
colorScheme: colorScheme,
), ),
], ],
), ),
), ),
), ),
], ],
), ),
), ),
@@ -214,6 +200,8 @@ class _RecentDonorsCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_DonorTile(name: 'J', colorScheme: colorScheme),
_DonorTile(name: 'Julian', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme), _DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile( _DonorTile(
name: '283Fabio', name: '283Fabio',
@@ -417,3 +405,34 @@ class _DonorTile extends StatelessWidget {
); );
} }
} }
class _NoticeLine extends StatelessWidget {
final IconData icon;
final String text;
final ColorScheme colorScheme;
const _NoticeLine({
required this.icon,
required this.text,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface,
),
),
),
],
);
}
}
@@ -9,6 +9,8 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerStatefulWidget { class DownloadSettingsPage extends ConsumerStatefulWidget {
@@ -20,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
} }
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> { class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon']; static const _builtInServices = ['tidal', 'qobuz'];
int _androidSdkVersion = 0; int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false; bool _hasAllFilesAccess = false;
@@ -93,7 +95,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService); final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal'; final isTidalService = settings.defaultService == 'tidal';
@@ -246,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Select Tidal, Qobuz, or Amazon above to configure quality', 'Select Tidal or Qobuz above to configure quality',
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -346,7 +348,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ref, ref,
settings.folderOrganization, settings.folderOrganization,
), ),
showDivider: false, ),
SettingsSwitchItem(
icon: Icons.person_search_outlined,
title: context.l10n.downloadUseAlbumArtistForFolders,
subtitle: settings.useAlbumArtistForFolders
? context
.l10n
.downloadUseAlbumArtistForFoldersAlbumSubtitle
: context
.l10n
.downloadUseAlbumArtistForFoldersTrackSubtitle,
value: settings.useAlbumArtistForFolders,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
showDivider: false,
), ),
], ],
), ),
@@ -901,17 +918,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
// Note: iOS requires folder to have at least one file to be selectable // Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) { if (result != null) {
// iOS: Check if user selected iCloud Drive (not accessible by Go backend) // iOS: Validate the selected path is writable (not iCloud or container root)
if (Platform.isIOS) { if (Platform.isIOS) {
final isICloudPath = final validation = validateIosPath(result);
result.contains('Mobile Documents') || if (!validation.isValid) {
result.contains('CloudDocs') ||
result.contains('com~apple~CloudDocs');
if (isICloudPath) {
if (ctx.mounted) { if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.setupIcloudNotSupported), content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
@@ -1340,7 +1354,6 @@ class _ServiceSelector extends ConsumerWidget {
final isExtensionService = ![ final isExtensionService = ![
'tidal', 'tidal',
'qobuz', 'qobuz',
'amazon',
].contains(currentService); ].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService) ? extensionProviders.any((e) => e.id == currentService)
@@ -1367,15 +1380,6 @@ class _ServiceSelector extends ConsumerWidget {
isSelected: effectiveService == 'qobuz', isSelected: effectiveService == 'qobuz',
onTap: () => onChanged('qobuz'), onTap: () => onChanged('qobuz'),
), ),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
isDisabled: true,
disabledReason: 'Coming soon',
onTap: () {},
),
], ],
), ),
if (extensionProviders.isNotEmpty) ...[ if (extensionProviders.isNotEmpty) ...[
@@ -1411,15 +1415,11 @@ class _ServiceChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
final bool isDisabled;
final String? disabledReason;
const _ServiceChip({ const _ServiceChip({
required this.icon, required this.icon,
required this.label, required this.label,
required this.isSelected, required this.isSelected,
required this.onTap, required this.onTap,
this.isDisabled = false,
this.disabledReason,
}); });
@override @override
@@ -1434,66 +1434,39 @@ class _ServiceChip extends StatelessWidget {
) )
: colorScheme.surfaceContainerHigh; : colorScheme.surfaceContainerHigh;
final disabledColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.02),
colorScheme.surface,
)
: colorScheme.surfaceContainerLow;
return Expanded( return Expanded(
child: Tooltip( child: Material(
message: isDisabled && disabledReason != null ? disabledReason! : '', color: isSelected
child: Material( ? colorScheme.primaryContainer
color: isDisabled : unselectedColor,
? disabledColor borderRadius: BorderRadius.circular(12),
: isSelected child: InkWell(
? colorScheme.primaryContainer onTap: onTap,
: unselectedColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: InkWell( child: Padding(
onTap: isDisabled ? null : onTap, padding: const EdgeInsets.symmetric(vertical: 14),
borderRadius: BorderRadius.circular(12), child: Column(
child: Padding( children: [
padding: const EdgeInsets.symmetric(vertical: 14), Icon(
child: Column( icon,
children: [ color: isSelected
Icon( ? colorScheme.onPrimaryContainer
icon, : colorScheme.onSurfaceVariant,
color: isDisabled ),
? colorScheme.onSurface.withValues(alpha: 0.38) const SizedBox(height: 6),
: isSelected Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer ? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 6), ),
Text( ],
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected && !isDisabled
? FontWeight.w600
: FontWeight.normal,
color: isDisabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
if (isDisabled && disabledReason != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
disabledReason!,
style: TextStyle(
fontSize: 9,
color: colorScheme.onSurface.withValues(alpha: 0.38),
),
),
),
],
),
), ),
), ),
), ),
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget { class ExtensionDetailPage extends ConsumerStatefulWidget {
@@ -55,7 +56,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
); );
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final hasError = extension.status == 'error'; final hasError = extension.status == 'error';
return PopScope( return PopScope(
+2 -1
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart'; import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart'; import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionsPage extends ConsumerStatefulWidget { class ExtensionsPage extends ConsumerStatefulWidget {
@@ -51,7 +52,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true, // Always allow back gesture
+57 -23
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget { class LibrarySettingsPage extends ConsumerStatefulWidget {
@@ -30,7 +31,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
// -> /storage/emulated/0/Music // -> /storage/emulated/0/Music
try { try {
final uri = Uri.parse(path); final uri = Uri.parse(path);
final treePath = uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic" final treePath =
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final decoded = Uri.decodeComponent(treePath); final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) { if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}'; return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
@@ -156,10 +158,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return; return;
} }
await ref.read(localLibraryProvider.notifier).startScan( await ref
libraryPath, .read(localLibraryProvider.notifier)
forceFullScan: forceFullScan, .startScan(libraryPath, forceFullScan: forceFullScan);
);
} }
Future<void> _cancelScan() async { Future<void> _cancelScan() async {
@@ -216,7 +217,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final libraryState = ref.watch(localLibraryProvider); final libraryState = ref.watch(localLibraryProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
@@ -260,6 +261,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: _LibraryHeroCard( child: _LibraryHeroCard(
itemCount: libraryState.items.length, itemCount: libraryState.items.length,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning, isScanning: libraryState.isScanning,
scanProgress: libraryState.scanProgress, scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile, scanCurrentFile: libraryState.scanCurrentFile,
@@ -331,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6), color: colorScheme.tertiaryContainer.withValues(
alpha: 0.6,
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -347,17 +351,20 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
children: [ children: [
Text( Text(
'Scan cancelled', 'Scan cancelled',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
fontWeight: FontWeight.w600, ?.copyWith(
color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600,
), color: colorScheme.onTertiaryContainer,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'You can retry the scan when ready.', 'You can retry the scan when ready.',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8), ?.copyWith(
), color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.8),
),
), ),
], ],
), ),
@@ -493,6 +500,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
class _LibraryHeroCard extends StatelessWidget { class _LibraryHeroCard extends StatelessWidget {
final int itemCount; final int itemCount;
final int excludedDownloadedCount;
final bool isScanning; final bool isScanning;
final double scanProgress; final double scanProgress;
final String? scanCurrentFile; final String? scanCurrentFile;
@@ -502,6 +510,7 @@ class _LibraryHeroCard extends StatelessWidget {
const _LibraryHeroCard({ const _LibraryHeroCard({
required this.itemCount, required this.itemCount,
required this.excludedDownloadedCount,
required this.isScanning, required this.isScanning,
required this.scanProgress, required this.scanProgress,
this.scanCurrentFile, this.scanCurrentFile,
@@ -527,10 +536,13 @@ class _LibraryHeroCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final displayCount = isScanning
? scannedFiles
: itemCount + excludedDownloadedCount;
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 220, constraints: const BoxConstraints(minHeight: 220),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
@@ -626,12 +638,12 @@ class _LibraryHeroCard extends StatelessWidget {
), ),
], ],
), ),
const Spacer(), const SizedBox(height: 16),
FittedBox( FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
isScanning ? scannedFiles.toString() : itemCount.toString(), displayCount.toString(),
style: TextStyle( style: TextStyle(
fontSize: 48, fontSize: 48,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -644,17 +656,35 @@ class _LibraryHeroCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
isScanning isScanning
? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim() ? context.l10n
.libraryTracksCount(scannedFiles)
.replaceAll(scannedFiles.toString(), '')
.trim()
: context.l10n : context.l10n
.libraryTracksCount(itemCount) .libraryTracksCount(displayCount)
.replaceAll(itemCount.toString(), '') .replaceAll(displayCount.toString(), '')
.trim(), .trim(),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
), ),
if (!isScanning && excludedDownloadedCount > 0) ...[
const SizedBox(height: 4),
Text(
'$excludedDownloadedCount from Downloads history '
'(excluded from list)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.8,
),
),
),
],
if (isScanning && scanCurrentFile != null) ...[ if (isScanning && scanCurrentFile != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
LinearProgressIndicator( LinearProgressIndicator(
@@ -670,7 +700,9 @@ class _LibraryHeroCard extends StatelessWidget {
Icon( Icon(
Icons.history, Icons.history,
size: 14, size: 14,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
@@ -679,7 +711,9 @@ class _LibraryHeroCard extends StatelessWidget {
), ),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
), ),
), ),
], ],
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -126,7 +127,7 @@ class _LogScreenState extends State<LogScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final logs = _filteredLogs; final logs = _filteredLogs;
return PopScope( return PopScope(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget { class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
const MetadataProviderPriorityPage({super.key}); const MetadataProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: !_hasChanges, canPop: !_hasChanges,
+119 -61
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class OptionsSettingsPage extends ConsumerWidget { class OptionsSettingsPage extends ConsumerWidget {
@@ -16,7 +17,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final extensionState = ref.watch(extensionProvider); final extensionState = ref.watch(extensionProvider);
final hasExtensions = extensionState.extensions.isNotEmpty; final hasExtensions = extensionState.extensions.isNotEmpty;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true, // Always allow back gesture
@@ -24,46 +25,48 @@ class OptionsSettingsPage extends ConsumerWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 120 + topPadding, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxHeight = 120 + topPadding; final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding; final minHeight = kToolbarHeight + topPadding;
final expandRatio = final expandRatio =
((constraints.maxHeight - minHeight) / ((constraints.maxHeight - minHeight) /
(maxHeight - minHeight)) (maxHeight - minHeight))
.clamp(0.0, 1.0); .clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar( return FlexibleSpaceBar(
expandedTitleScale: 1.0, expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only( titlePadding: EdgeInsets.only(
left: leftPadding, left: leftPadding,
bottom: 16, bottom: 16,
),
title: Text(
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
), ),
), title: Text(
); context.l10n.optionsTitle,
}, style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource), child: SettingsSectionHeader(
title: context.l10n.sectionSearchSource,
),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
@@ -86,14 +89,18 @@ class OptionsSettingsPage extends ConsumerWidget {
children: [ children: [
Icon( Icon(
Icons.warning_amber_rounded, Icons.warning_amber_rounded,
color: Theme.of(context).colorScheme.onErrorContainer, color: Theme.of(
context,
).colorScheme.onErrorContainer,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
context.l10n.optionsSpotifyWarning, context.l10n.optionsSpotifyWarning,
style: TextStyle( style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer, color: Theme.of(
context,
).colorScheme.onErrorContainer,
fontSize: 12, fontSize: 12,
), ),
), ),
@@ -107,7 +114,11 @@ class OptionsSettingsPage extends ConsumerWidget {
icon: Icons.key, icon: Icons.key,
title: context.l10n.optionsSpotifyCredentials, title: context.l10n.optionsSpotifyCredentials,
subtitle: settings.spotifyClientId.isNotEmpty subtitle: settings.spotifyClientId.isNotEmpty
? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId) ? context.l10n.optionsSpotifyCredentialsConfigured(
settings.spotifyClientId.length > 8
? settings.spotifyClientId.substring(0, 8)
: settings.spotifyClientId,
)
: context.l10n.optionsSpotifyCredentialsRequired, : context.l10n.optionsSpotifyCredentialsRequired,
onTap: () => onTap: () =>
_showSpotifyCredentialsDialog(context, ref, settings), _showSpotifyCredentialsDialog(context, ref, settings),
@@ -168,7 +179,9 @@ class OptionsSettingsPage extends ConsumerWidget {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionPerformance), child: SettingsSectionHeader(
title: context.l10n.sectionPerformance,
),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
@@ -277,9 +290,7 @@ class OptionsSettingsPage extends ConsumerWidget {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.dialogClearHistoryTitle), title: Text(context.l10n.dialogClearHistoryTitle),
content: Text( content: Text(context.l10n.dialogClearHistoryMessage),
context.l10n.dialogClearHistoryMessage,
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -289,11 +300,14 @@ class OptionsSettingsPage extends ConsumerWidget {
onPressed: () { onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory(); ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.snackbarHistoryCleared)),
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared))); );
}, },
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)), child: Text(
context.l10n.dialogClear,
style: TextStyle(color: colorScheme.error),
),
), ),
], ],
), ),
@@ -323,7 +337,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final removed = await ref final removed = await ref
.read(downloadHistoryProvider.notifier) .read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads(); .cleanupOrphanedDownloads();
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); // Close loading dialog Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -339,9 +353,9 @@ class OptionsSettingsPage extends ConsumerWidget {
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); // Close loading dialog Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Error: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Error: $e')));
} }
} }
} }
@@ -493,7 +507,11 @@ class OptionsSettingsPage extends ConsumerWidget {
.setSpotifyCredentials(clientId, clientSecret); .setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)), SnackBar(
content: Text(
context.l10n.snackbarCredentialsSaved,
),
),
); );
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -524,7 +542,11 @@ class OptionsSettingsPage extends ConsumerWidget {
.clearSpotifyCredentials(); .clearSpotifyCredentials();
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)), SnackBar(
content: Text(
context.l10n.snackbarCredentialsCleared,
),
),
); );
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
@@ -582,7 +604,9 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
Text( Text(
currentValue == 1 currentValue == 1
? context.l10n.optionsConcurrentSequential ? context.l10n.optionsConcurrentSequential
: context.l10n.optionsConcurrentParallel(currentValue), : context.l10n.optionsConcurrentParallel(
currentValue,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -612,6 +636,18 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
isSelected: currentValue == 3, isSelected: currentValue == 3,
onTap: () => onChanged(3), onTap: () => onChanged(3),
), ),
const SizedBox(width: 8),
_ConcurrentChip(
label: '4',
isSelected: currentValue == 4,
onTap: () => onChanged(4),
),
const SizedBox(width: 8),
_ConcurrentChip(
label: '5',
isSelected: currentValue == 5,
onTap: () => onChanged(5),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -837,20 +873,21 @@ class _MetadataSourceSelector extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
Extension? activeExtension; Extension? activeExtension;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) {
activeExtension = extState.extensions activeExtension = extState.extensions
.where((e) => e.id == settings.searchProvider && e.enabled) .where((e) => e.id == settings.searchProvider && e.enabled)
.firstOrNull; .firstOrNull;
} }
final hasExtensionSearch = activeExtension != null; final hasExtensionSearch = activeExtension != null;
String? extensionName; String? extensionName;
if (hasExtensionSearch) { if (hasExtensionSearch) {
extensionName = activeExtension.displayName; extensionName = activeExtension.displayName;
} }
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -868,8 +905,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
? context.l10n.optionsUsingExtension(extensionName!) ? context.l10n.optionsUsingExtension(extensionName!)
: context.l10n.optionsPrimaryProviderSubtitle, : context.l10n.optionsPrimaryProviderSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasExtensionSearch color: hasExtensionSearch
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
), ),
@@ -922,6 +959,27 @@ class _MetadataSourceSelector extends ConsumerWidget {
], ],
), ),
], ],
if (currentSource == 'spotify' && !hasExtensionSearch) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 16,
color: colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.optionsSpotifyDeprecationWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
], ],
), ),
); );
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget { class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key}); const ProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: !_hasChanges, canPop: !_hasChanges,
+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/library_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart'; import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart'; import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/cache_management_page.dart';
import 'package:spotiflac_android/screens/settings/donate_page.dart'; import 'package:spotiflac_android/screens/settings/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget { class SettingsTab extends ConsumerWidget {
@@ -18,7 +20,7 @@ class SettingsTab extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -73,19 +75,29 @@ class SettingsTab extends ConsumerWidget {
icon: Icons.download_outlined, icon: Icons.download_outlined,
title: l10n.settingsDownload, title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle, subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()), onTap: () =>
_navigateTo(context, const DownloadSettingsPage()),
), ),
SettingsItem( SettingsItem(
icon: Icons.library_music_outlined, icon: Icons.library_music_outlined,
title: l10n.settingsLocalLibrary, title: l10n.settingsLocalLibrary,
subtitle: l10n.settingsLocalLibrarySubtitle, subtitle: l10n.settingsLocalLibrarySubtitle,
onTap: () => _navigateTo(context, const LibrarySettingsPage()), onTap: () =>
_navigateTo(context, const LibrarySettingsPage()),
),
SettingsItem(
icon: Icons.storage_outlined,
title: l10n.settingsCache,
subtitle: l10n.settingsCacheSubtitle,
onTap: () =>
_navigateTo(context, const CacheManagementPage()),
), ),
SettingsItem( SettingsItem(
icon: Icons.tune_outlined, icon: Icons.tune_outlined,
title: l10n.settingsOptions, title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle, subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()), onTap: () =>
_navigateTo(context, const OptionsSettingsPage()),
), ),
SettingsItem( SettingsItem(
icon: Icons.extension_outlined, icon: Icons.extension_outlined,
@@ -138,7 +150,7 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) { void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push( Navigator.of(context).push(
PageRouteBuilder( PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page, pageBuilder: (context, animation, secondaryAnimation) => page,
@@ -146,9 +158,10 @@ class SettingsTab extends ConsumerWidget {
const begin = Offset(1.0, 0.0); const begin = Offset(1.0, 0.0);
const end = Offset.zero; const end = Offset.zero;
const curve = Curves.easeInOut; const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain( var tween = Tween(
CurveTween(curve: curve), begin: begin,
); end: end,
).chain(CurveTween(curve: curve));
return SlideTransition( return SlideTransition(
position: animation.drive(tween), position: animation.drive(tween),
child: child, child: child,
-540
View File
@@ -1,540 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (Lossless)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), // Spotify Green
const Color(0xFF6750A4), // Purple
const Color(0xFF0061A4), // Blue
const Color(0xFF006E1C), // Green
const Color(0xFFBA1A1A), // Red
const Color(0xFF984061), // Pink
const Color(0xFF7D5260), // Brown
const Color(0xFF006874), // Teal
const Color(0xFFFF6F00), // Orange
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3)
: null,
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
hintText: '{artist} - {title}',
),
),
const SizedBox(height: 16),
Text(
'Available placeholders:',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'{title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
-520
View File
@@ -1,520 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsTab extends ConsumerStatefulWidget {
const SettingsTab({super.key});
@override
ConsumerState<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
// Bottom padding for navigation bar
const SizedBox(height: 16),
],
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), const Color(0xFF6750A4), const Color(0xFF0061A4),
const Color(0xFF006E1C), const Color(0xFFBA1A1A), const Color(0xFF984061),
const Color(0xFF7D5260), const Color(0xFF006874), const Color(0xFFFF6F00),
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white) : null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}')),
const SizedBox(height: 16),
Text('Available placeholders:', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Text('{title}, {artist}, {album}, {track}, {year}, {disc}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
+132 -69
View File
@@ -9,6 +9,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
class SetupScreen extends ConsumerStatefulWidget { class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key}); const SetupScreen({super.key});
@@ -248,7 +249,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.setupUseDefaultFolder), title: Text(context.l10n.setupUseDefaultFolder),
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'), content: Text(
'${context.l10n.setupNoFolderSelected}\n\n$defaultDir',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
@@ -320,6 +323,22 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Navigator.pop(ctx); Navigator.pop(ctx);
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) { if (result != null) {
// iOS: Validate the selected path is writable
if (Platform.isIOS) {
final validation = validateIosPath(result);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(validation.errorReason ?? 'Invalid folder selected'),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
}
setState(() => _selectedDirectory = result); setState(() => _selectedDirectory = result);
} }
}, },
@@ -576,37 +595,60 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} }
Widget _buildWelcomeStep(ColorScheme colorScheme) { Widget _buildWelcomeStep(ColorScheme colorScheme) {
return Padding( return LayoutBuilder(
padding: const EdgeInsets.all(24), builder: (context, constraints) {
child: Column( final shortestSide = MediaQuery.sizeOf(context).shortestSide;
mainAxisAlignment: MainAxisAlignment.center, final textScale = MediaQuery.textScalerOf(
children: [ context,
Image.asset( ).scale(1.0).clamp(1.0, 1.4);
'assets/images/logo-transparant.png', final logoSize = (shortestSide * 0.24).clamp(80.0, 104.0);
width: 104, final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
height: 104, final subtitleGap = (shortestSide * 0.04).clamp(8.0, 16.0);
color: colorScheme.primary, final minContentHeight = constraints.maxHeight > 48
fit: BoxFit.contain, ? constraints.maxHeight - 48
), : 0.0;
const SizedBox(height: 32),
Text( return SingleChildScrollView(
context.l10n.appName, padding: const EdgeInsets.all(24),
style: Theme.of(context).textTheme.displaySmall?.copyWith( child: ConstrainedBox(
fontWeight: FontWeight.bold, constraints: BoxConstraints(minHeight: minContentHeight),
color: colorScheme.onSurface, child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo-transparant.png',
width: logoSize,
height: logoSize,
color: colorScheme.primary,
fit: BoxFit.contain,
),
SizedBox(height: titleGap),
Text(
context.l10n.appName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
fontSize:
(Theme.of(context).textTheme.displaySmall?.fontSize ??
36) *
(1 + ((textScale - 1) * 0.18)),
),
),
SizedBox(height: subtitleGap),
Text(
context.l10n.setupDownloadInFlac,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
), ),
), ),
const SizedBox(height: 16), );
Text( },
context.l10n.setupDownloadInFlac,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
); );
} }
@@ -833,41 +875,58 @@ class _StepLayout extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Padding( return LayoutBuilder(
padding: const EdgeInsets.all(24), builder: (context, constraints) {
child: Column( final shortestSide = MediaQuery.sizeOf(context).shortestSide;
mainAxisAlignment: MainAxisAlignment.center, final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
children: [ final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
Container( final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
padding: const EdgeInsets.all(24), final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0);
decoration: BoxDecoration( final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0);
color: colorScheme.surfaceContainerHighest, final minContentHeight = constraints.maxHeight > 48
shape: BoxShape.circle, ? constraints.maxHeight - 48
: 0.0;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minContentHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(icon, size: iconSize, color: colorScheme.primary),
),
SizedBox(height: titleGap),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
SizedBox(height: descriptionGap),
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
textAlign: TextAlign.center,
),
SizedBox(height: actionGap),
child,
],
), ),
child: Icon(icon, size: 48, color: colorScheme.primary),
), ),
const SizedBox(height: 32), );
Text( },
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
child,
],
),
); );
} }
} }
@@ -881,21 +940,25 @@ class _SuccessCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer, color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer), Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Expanded(
text, child: Text(
style: TextStyle( text,
fontWeight: FontWeight.bold, style: TextStyle(
color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
+2 -1
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget { class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key}); const StoreTab({super.key});
@@ -44,7 +45,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(storeProvider); final state = ref.watch(storeProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: RefreshIndicator( body: RefreshIndicator(
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; int _currentPage = 0;
static const int _totalPages = 6; static const int _totalPages = 6;
double _responsiveScale({
required BuildContext context,
double min = 0.82,
double max = 1.08,
double baseShortestSide = 390,
}) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = shortestSide / baseShortestSide;
if (scale < min) return min;
if (scale > max) return max;
return scale;
}
double _effectiveTextScale(BuildContext context) {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
@@ -55,6 +75,15 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final l10n = context.l10n; final l10n = context.l10n;
final isLastPage = _currentPage == _totalPages - 1; final isLastPage = _currentPage == _totalPages - 1;
final scale = _responsiveScale(context: context, min: 0.86, max: 1.05);
final textScale = _effectiveTextScale(context);
final topBarPaddingH = 24 * scale;
final topBarPaddingV = 16 * scale;
final pageIndicatorHeight = 8 * scale;
final pageIndicatorWidth = 8 * scale;
final activeIndicatorWidth = 32 * scale;
final bottomGap = (32 * scale) + ((textScale - 1) * 8);
final actionButtonHeight = (56 * scale) + ((textScale - 1) * 6);
return Scaffold( return Scaffold(
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -63,7 +92,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
children: [ children: [
// Top Navigation Bar // Top Navigation Bar
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
vertical: topBarPaddingV,
),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -199,9 +231,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
margin: const EdgeInsets.symmetric(horizontal: 4), margin: EdgeInsets.symmetric(horizontal: 4 * scale),
height: 8, height: pageIndicatorHeight,
width: isActive ? 32 : 8, width: isActive
? activeIndicatorWidth
: pageIndicatorWidth,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive color: isActive
? colorScheme.primary ? colorScheme.primary
@@ -211,11 +245,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
); );
}), }),
), ),
const SizedBox(height: 32), SizedBox(height: bottomGap),
// Action Button // Action Button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 56, height: actionButtonHeight,
child: FilledButton( child: FilledButton(
onPressed: _nextPage, onPressed: _nextPage,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@@ -520,104 +554,114 @@ class _InteractiveDownloadExampleState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return LayoutBuilder(
padding: const EdgeInsets.all(20), builder: (context, constraints) {
decoration: BoxDecoration( final cardWidth = constraints.maxWidth;
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
borderRadius: BorderRadius.circular(28), final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
border: Border.all( final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
), return Container(
), padding: const EdgeInsets.all(20),
child: Row( decoration: BoxDecoration(
children: [ color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
Container( borderRadius: BorderRadius.circular(28),
width: 72, border: Border.all(
height: 72, color: colorScheme.outlineVariant.withValues(alpha: 0.5),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: 36,
color: colorScheme.onPrimaryContainer,
), ),
), ),
const SizedBox(width: 20), child: Row(
Expanded( children: [
child: Column( Container(
crossAxisAlignment: CrossAxisAlignment.start, width: coverSize,
children: [ height: coverSize,
Container( decoration: BoxDecoration(
width: 140, color: colorScheme.primaryContainer,
height: 14, borderRadius: BorderRadius.circular(20),
decoration: BoxDecoration( ),
color: colorScheme.onSurface, child: Icon(
borderRadius: BorderRadius.circular(7), Icons.album_rounded,
), size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
), ),
const SizedBox(height: 10),
if (_isDownloading)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: _progress,
minHeight: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
else
Container(
width: 90,
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (_isCompleted ? Colors.green : colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
), ),
child: _isDownloading const SizedBox(width: 20),
? SizedBox( Expanded(
width: 28, child: Column(
height: 28, crossAxisAlignment: CrossAxisAlignment.start,
child: CircularProgressIndicator( children: [
strokeWidth: 3, Container(
color: colorScheme.onPrimary, width: (cardWidth * 0.35).clamp(100.0, 160.0),
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(7),
), ),
)
: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: 28,
), ),
), const SizedBox(height: 10),
if (_isDownloading)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: _progress,
minHeight: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
else
Container(
width: (cardWidth * 0.22).clamp(70.0, 100.0),
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.all(buttonPadding),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color:
(_isCompleted ? Colors.green : colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: _isDownloading
? SizedBox(
width: buttonIconSize,
height: buttonIconSize,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.onPrimary,
),
)
: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: buttonIconSize,
),
),
),
],
), ),
], );
), },
); );
} }
} }
@@ -644,6 +688,18 @@ class _TutorialPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final scale = (shortestSide / 390).clamp(0.86, 1.05);
final topGap = (24 * scale).clamp(16.0, 24.0);
final iconPadding = (24 * scale).clamp(18.0, 24.0);
final iconSize = (56 * scale).clamp(44.0, 56.0);
final iconTextGap = (48 * scale).clamp(28.0, 48.0);
final descriptionGap = (20 * scale).clamp(12.0, 20.0);
final contentGap = (56 * scale) + ((textScale - 1) * 10);
final bottomGap = (32 * scale).clamp(20.0, 32.0);
// Parallax effect logic (simplified for StatelessWidget) // Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable // In a real advanced implementation we'd pass the Controller's listenable
@@ -656,23 +712,23 @@ class _TutorialPage extends StatelessWidget {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 24), SizedBox(height: topGap),
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0), transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
padding: const EdgeInsets.all(24), padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15), color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
icon, icon,
size: 56, size: iconSize,
color: iconColor ?? colorScheme.primary, color: iconColor ?? colorScheme.primary,
), ),
), ),
const SizedBox(height: 48), SizedBox(height: iconTextGap),
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0, opacity: isActive ? 1.0 : 0.0,
@@ -687,7 +743,7 @@ class _TutorialPage extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 20), SizedBox(height: descriptionGap),
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0, opacity: isActive ? 1.0 : 0.0,
@@ -697,14 +753,14 @@ class _TutorialPage extends StatelessWidget {
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
height: 1.5, height: 1.5,
fontSize: 16, fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 56), SizedBox(height: contentGap),
content, // The content itself now handles its own internal animations content, // The content itself now handles its own internal animations
const SizedBox(height: 32), SizedBox(height: bottomGap),
], ],
), ),
); );
+114 -83
View File
@@ -21,7 +21,7 @@ class CsvImportService {
final file = File(result.files.single.path!); final file = File(result.files.single.path!);
final content = await file.readAsString(); final content = await file.readAsString();
final tracks = _parseCsv(content); final tracks = _parseCsv(content);
if (tracks.isNotEmpty) { if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress); return await _enrichTracksMetadata(tracks, onProgress: onProgress);
} }
@@ -39,43 +39,50 @@ class CsvImportService {
}) async { }) async {
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...'); _log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
final enrichedTracks = <Track>[]; final enrichedTracks = <Track>[];
for (int i = 0; i < tracks.length; i++) { for (int i = 0; i < tracks.length; i++) {
final track = tracks[i]; final track = tracks[i];
onProgress?.call(i + 1, tracks.length); onProgress?.call(i + 1, tracks.length);
if (track.coverUrl == null || track.duration == 0) { if (track.coverUrl == null || track.duration == 0) {
Map<String, dynamic>? trackData; Map<String, dynamic>? trackData;
if (track.isrc != null && track.isrc!.isNotEmpty) { if (track.isrc != null && track.isrc!.isNotEmpty) {
try { try {
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!); trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
_log.d('ISRC enrichment success for ${track.name}'); _log.d('ISRC enrichment success for ${track.name}');
} catch (e) { } catch (e) {
_log.w('ISRC search failed for ${track.name}, trying text search...'); _log.w(
'ISRC search failed for ${track.name}, trying text search...',
);
} }
} }
if (trackData == null) { if (trackData == null) {
try { try {
final query = '${track.artistName} ${track.name}'; final query = '${track.artistName} ${track.name}';
final searchResult = await PlatformBridge.searchDeezerAll(query, trackLimit: 5); final searchResult = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 5,
);
if (searchResult.containsKey('tracks')) { if (searchResult.containsKey('tracks')) {
final tracksList = searchResult['tracks'] as List<dynamic>?; final tracksList = searchResult['tracks'] as List<dynamic>?;
if (tracksList != null && tracksList.isNotEmpty) { if (tracksList != null && tracksList.isNotEmpty) {
for (final result in tracksList) { for (final result in tracksList) {
final resultMap = result as Map<String, dynamic>; final resultMap = result as Map<String, dynamic>;
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? ''; final resultName =
(resultMap['name'] as String?)?.toLowerCase() ?? '';
final trackNameLower = track.name.toLowerCase(); final trackNameLower = track.name.toLowerCase();
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) { if (resultName.contains(trackNameLower) ||
trackNameLower.contains(resultName)) {
trackData = resultMap; trackData = resultMap;
_log.d('Text search match for ${track.name}: $resultName'); _log.d('Text search match for ${track.name}: $resultName');
break; break;
} }
} }
if (trackData == null && tracksList.isNotEmpty) { if (trackData == null && tracksList.isNotEmpty) {
trackData = tracksList.first as Map<String, dynamic>; trackData = tracksList.first as Map<String, dynamic>;
_log.d('Using first search result for ${track.name}'); _log.d('Using first search result for ${track.name}');
@@ -86,38 +93,44 @@ class CsvImportService {
_log.w('Text search also failed for ${track.name}: $e'); _log.w('Text search also failed for ${track.name}: $e');
} }
} }
if (trackData != null) { if (trackData != null) {
final coverUrl = trackData['images'] as String?; final coverUrl = trackData['images'] as String?;
final durationMs = trackData['duration_ms'] as int? ?? 0; final durationMs = trackData['duration_ms'] as int? ?? 0;
final deezerIdRaw = trackData['spotify_id'] as String?; final deezerIdRaw = trackData['spotify_id'] as String?;
enrichedTracks.add(Track( enrichedTracks.add(
id: deezerIdRaw ?? track.id, Track(
name: trackData['name'] as String? ?? track.name, id: deezerIdRaw ?? track.id,
artistName: trackData['artists'] as String? ?? track.artistName, name: trackData['name'] as String? ?? track.name,
albumName: trackData['album_name'] as String? ?? track.albumName, artistName: trackData['artists'] as String? ?? track.artistName,
albumArtist: trackData['album_artist'] as String?, albumName: trackData['album_name'] as String? ?? track.albumName,
coverUrl: coverUrl ?? track.coverUrl, albumArtist: trackData['album_artist'] as String?,
isrc: trackData['isrc'] as String? ?? track.isrc, coverUrl: coverUrl ?? track.coverUrl,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration, isrc: trackData['isrc'] as String? ?? track.isrc,
trackNumber: trackData['track_number'] as int? ?? track.trackNumber, duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
discNumber: trackData['disc_number'] as int? ?? track.discNumber, trackNumber:
releaseDate: trackData['release_date'] as String? ?? track.releaseDate, trackData['track_number'] as int? ?? track.trackNumber,
)); discNumber: trackData['disc_number'] as int? ?? track.discNumber,
releaseDate:
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s'); trackData['release_date'] as String? ?? track.releaseDate,
),
);
_log.d(
'Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s',
);
if (i < tracks.length - 1) { if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(const Duration(milliseconds: 100));
} }
continue; continue;
} }
} }
enrichedTracks.add(track); enrichedTracks.add(track);
} }
_log.i('Enrichment complete: ${enrichedTracks.length} tracks'); _log.i('Enrichment complete: ${enrichedTracks.length} tracks');
return enrichedTracks; return enrichedTracks;
} }
@@ -136,8 +149,8 @@ class CsvImportService {
final headers = _parseLine(lines[startIdx]); final headers = _parseLine(lines[startIdx]);
final colMap = <String, int>{}; final colMap = <String, int>{};
for (int i = 0; i < headers.length; i++) { for (int i = 0; i < headers.length; i++) {
String h = _cleanValue(headers[i]).toLowerCase(); String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i; colMap[h] = i;
} }
_log.d('CSV Headers: ${colMap.keys.toList()}'); _log.d('CSV Headers: ${colMap.keys.toList()}');
@@ -147,48 +160,67 @@ class CsvImportService {
if (line.isEmpty) continue; if (line.isEmpty) continue;
final values = _parseLine(line); final values = _parseLine(line);
String? getVal(List<String> keys) { String? getVal(List<String> keys) {
return _getValue(values, colMap, keys); return _getValue(values, colMap, keys);
} }
String? trackName = getVal(['track name', 'track', 'name', 'title']); String? trackName = getVal(['track name', 'track', 'name', 'title']);
String? artistName = getVal(['artist name(s)', 'artist name', 'artist', 'artists']); String? artistName = getVal([
'artist name(s)',
'artist name',
'artist',
'artists',
]);
String? albumName = getVal(['album name', 'album']); String? albumName = getVal(['album name', 'album']);
String? isrc = getVal(['isrc']); String? isrc = getVal(['isrc']);
String? spotifyId = getVal(['track uri', 'spotify - id', 'spotify id', 'spotify_id', 'id', 'uri']); String? spotifyId = getVal([
'track uri',
'spotify - id',
'spotify id',
'spotify_id',
'id',
'uri',
]);
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) { if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.replaceAll('spotify:track:', ''); spotifyId = spotifyId.replaceAll('spotify:track:', '');
} }
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) { if ((trackName != null && trackName.isNotEmpty && artistName != null) ||
tracks.add(Track( (spotifyId != null && spotifyId.isNotEmpty)) {
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i', tracks.add(
name: trackName ?? 'Unknown Track', Track(
artistName: artistName ?? 'Unknown Artist', id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
albumName: albumName ?? 'Unknown Album', name: trackName ?? 'Unknown Track',
isrc: isrc, artistName: artistName ?? 'Unknown Artist',
duration: 0, // Will be updated by enrichment later albumName: albumName ?? 'Unknown Album',
coverUrl: null, // Will be fetched by enrichment isrc: isrc,
)); duration: 0, // Will be updated by enrichment later
coverUrl: null, // Will be fetched by enrichment
),
);
} }
} }
_log.i('Parsed ${tracks.length} tracks from CSV'); _log.i('Parsed ${tracks.length} tracks from CSV');
return tracks; return tracks;
} }
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) { static String? _getValue(
for (final key in possibleKeys) { List<String> values,
if (colMap.containsKey(key)) { Map<String, int> colMap,
final index = colMap[key]!; List<String> possibleKeys,
if (index < values.length) { ) {
return _cleanValue(values[index]); for (final key in possibleKeys) {
} if (colMap.containsKey(key)) {
} final index = colMap[key]!;
if (index < values.length) {
return _cleanValue(values[index]);
}
} }
return null; }
return null;
} }
static String _cleanValue(String val) { static String _cleanValue(String val) {
@@ -201,30 +233,29 @@ class CsvImportService {
} }
static List<String> _parseLine(String line) { static List<String> _parseLine(String line) {
final List<String> result = []; final List<String> result = [];
bool inQuote = false; bool inQuote = false;
StringBuffer buffer = StringBuffer(); var buffer = StringBuffer();
for (int i=0; i<line.length; i++) { for (int i = 0; i < line.length; i++) {
String char = line[i]; final char = line[i];
if (char == '"') { if (char == '"') {
if (i + 1 < line.length && line[i+1] == '"') { if (inQuote && i + 1 < line.length && line[i + 1] == '"') {
buffer.write('"'); buffer.write('"');
buffer.write('"'); i++;
i++; // Skip next quote char loop } else {
buffer.write('"'); // Write 2nd quote inQuote = !inQuote;
} else { }
inQuote = !inQuote; continue;
buffer.write(char); }
} if (char == ',' && !inQuote) {
} else if (char == ',' && !inQuote) { result.add(buffer.toString());
result.add(buffer.toString()); buffer = StringBuffer();
buffer.clear(); continue;
} else { }
buffer.write(char); buffer.write(char);
} }
} result.add(buffer.toString());
result.add(buffer.toString()); return result;
return result;
} }
} }
+46 -8
View File
@@ -10,6 +10,9 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg'); final _log = AppLogger('FFmpeg');
class FFmpegService { class FFmpegService {
static const int _commandLogPreviewLength = 300;
static int _tempEmbedCounter = 0;
static String _buildOutputPath(String inputPath, String extension) { static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
final inputFile = File(inputPath); final inputFile = File(inputPath);
@@ -26,6 +29,33 @@ class FFmpegService {
return outputPath; return outputPath;
} }
static String _previewCommandForLog(String command) {
final redacted = command
.replaceAll(
RegExp(r'-metadata\s+lyrics="[^"]*"', caseSensitive: false),
'-metadata lyrics="<redacted>"',
)
.replaceAll(
RegExp(r'-metadata\s+unsyncedlyrics="[^"]*"', caseSensitive: false),
'-metadata unsyncedlyrics="<redacted>"',
)
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (redacted.length <= _commandLogPreviewLength) {
return redacted;
}
return '${redacted.substring(0, _commandLogPreviewLength)}...';
}
static String _nextTempEmbedPath(String tempDirPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
_tempEmbedCounter = (_tempEmbedCounter + 1) & 0x7fffffff;
final timestamp = DateTime.now().microsecondsSinceEpoch;
final processId = pid;
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
}
static Future<FFmpegResult> _execute(String command) async { static Future<FFmpegResult> _execute(String command) async {
try { try {
final session = await FFmpegKit.execute(command); final session = await FFmpegKit.execute(command);
@@ -248,8 +278,7 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" '); cmdBuffer.write('-i "$flacPath" ');
@@ -280,7 +309,7 @@ class FFmpegService {
cmdBuffer.write('"$tempOutput" -y'); cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString(); final command = cmdBuffer.toString();
_log.d('Executing FFmpeg command: $command'); _log.d('Executing FFmpeg command: ${_previewCommandForLog(command)}');
final result = await _execute(command); final result = await _execute(command);
@@ -326,8 +355,7 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" '); cmdBuffer.write('-i "$mp3Path" ');
@@ -337,6 +365,7 @@ class FFmpegService {
} }
cmdBuffer.write('-map 0:a '); cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
if (coverPath != null) { if (coverPath != null) {
cmdBuffer.write('-map 1:0 '); cmdBuffer.write('-map 1:0 ');
@@ -359,7 +388,9 @@ class FFmpegService {
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y'); cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString(); final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command'); _log.d(
'Executing FFmpeg MP3 embed command: ${_previewCommandForLog(command)}',
);
final result = await _execute(command); final result = await _execute(command);
@@ -406,12 +437,13 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$opusPath" '); cmdBuffer.write('-i "$opusPath" ');
cmdBuffer.write('-map 0:a '); cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
cmdBuffer.write('-map_metadata:s:a -1 ');
cmdBuffer.write('-c:a copy '); cmdBuffer.write('-c:a copy ');
if (metadata != null) { if (metadata != null) {
@@ -625,6 +657,12 @@ class FFmpegService {
case 'UNSYNCEDLYRICS': case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value; id3Map['lyrics'] = value;
break; break;
case 'COMPOSER':
id3Map['composer'] = value;
break;
case 'COMMENT':
id3Map['comment'] = value;
break;
default: default:
id3Map[key.toLowerCase()] = value; id3Map[key.toLowerCase()] = value;
} }
+31 -31
View File
@@ -30,7 +30,7 @@ class NotificationService {
iOS: iosSettings, iOS: iosSettings,
); );
await _notifications.initialize(initSettings); await _notifications.initialize(settings: initSettings);
if (Platform.isAndroid) { if (Platform.isAndroid) {
await _notifications await _notifications
@@ -90,10 +90,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
downloadProgressId, id: downloadProgressId,
'Downloading $trackName', title: 'Downloading $trackName',
'$artistName$percentage%', body: '$artistName$percentage%',
details, notificationDetails: details,
); );
} }
@@ -133,10 +133,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
downloadProgressId, id: downloadProgressId,
'Finalizing $trackName', title: 'Finalizing $trackName',
'$artistName • Embedding metadata...', body: '$artistName • Embedding metadata...',
details, notificationDetails: details,
); );
} }
@@ -183,10 +183,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
downloadProgressId, id: downloadProgressId,
title, title: title,
'$trackName - $artistName', body: '$trackName - $artistName',
details, notificationDetails: details,
); );
} }
@@ -223,15 +223,15 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
downloadProgressId, id: downloadProgressId,
title, title: title,
'$completedCount tracks downloaded successfully', body: '$completedCount tracks downloaded successfully',
details, notificationDetails: details,
); );
} }
Future<void> cancelDownloadNotification() async { Future<void> cancelDownloadNotification() async {
await _notifications.cancel(downloadProgressId); await _notifications.cancel(id: downloadProgressId);
} }
Future<void> showUpdateDownloadProgress({ Future<void> showUpdateDownloadProgress({
@@ -274,10 +274,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
updateDownloadId, id: updateDownloadId,
'Downloading SpotiFLAC v$version', title: 'Downloading SpotiFLAC v$version',
'$receivedMB / $totalMB MB • $percentage%', body: '$receivedMB / $totalMB MB • $percentage%',
details, notificationDetails: details,
); );
} }
@@ -307,10 +307,10 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
updateDownloadId, id: updateDownloadId,
'Update Ready', title: 'Update Ready',
'SpotiFLAC v$version downloaded. Tap to install.', body: 'SpotiFLAC v$version downloaded. Tap to install.',
details, notificationDetails: details,
); );
} }
@@ -339,14 +339,14 @@ class NotificationService {
); );
await _notifications.show( await _notifications.show(
updateDownloadId, id: updateDownloadId,
'Update Failed', title: 'Update Failed',
'Could not download update. Try again later.', body: 'Could not download update. Try again later.',
details, notificationDetails: details,
); );
} }
Future<void> cancelUpdateNotification() async { Future<void> cancelUpdateNotification() async {
await _notifications.cancel(updateDownloadId); await _notifications.cancel(id: updateDownloadId);
} }
} }
-90
View File
@@ -1,90 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
/// Service for extracting dominant colors from images
/// Uses caching to avoid re-extraction and small image size for speed
class PaletteService {
static final PaletteService instance = PaletteService._();
PaletteService._();
final Map<String, Color> _colorCache = {};
/// Extract dominant color from a network image URL
/// Uses small image size and limited colors for speed
Future<Color?> extractDominantColor(String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
return null;
}
final cached = _colorCache[imageUrl];
if (cached != null) {
return cached;
}
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(imageUrl),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[imageUrl] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService error: $e');
return null;
}
}
Future<Color?> extractDominantColorFromFile(String? filePath) async {
if (filePath == null || filePath.isEmpty) return null;
final cached = _colorCache[filePath];
if (cached != null) {
return cached;
}
try {
final file = File(filePath);
if (!await file.exists()) return null;
final paletteGenerator = await PaletteGenerator.fromImageProvider(
FileImage(file),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[filePath] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService file error: $e');
return null;
}
}
void clearCache() {
_colorCache.clear();
}
Color? getCached(String? imageUrl) {
if (imageUrl == null) return null;
return _colorCache[imageUrl];
}
}
+194 -61
View File
@@ -374,6 +374,55 @@ class PlatformBridge {
await _channel.invokeMethod('cleanupConnections'); await _channel.invokeMethod('cleanupConnections');
} }
static Future<Map<String, dynamic>> downloadCoverToFile(
String coverUrl,
String outputPath, {
bool maxQuality = true,
}) async {
final result = await _channel.invokeMethod('downloadCoverToFile', {
'cover_url': coverUrl,
'output_path': outputPath,
'max_quality': maxQuality,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> extractCoverToFile(
String audioPath,
String outputPath,
) async {
final result = await _channel.invokeMethod('extractCoverToFile', {
'audio_path': audioPath,
'output_path': outputPath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> fetchAndSaveLyrics({
required String trackName,
required String artistName,
required String spotifyId,
required int durationMs,
required String outputPath,
}) async {
final result = await _channel.invokeMethod('fetchAndSaveLyrics', {
'track_name': trackName,
'artist_name': artistName,
'spotify_id': spotifyId,
'duration_ms': durationMs,
'output_path': outputPath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> reEnrichFile(Map<String, dynamic> request) async {
final requestJSON = jsonEncode(request);
final result = await _channel.invokeMethod('reEnrichFile', {
'request_json': requestJSON,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async { static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', { final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath, 'file_path': filePath,
@@ -381,6 +430,27 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
static Future<Map<String, dynamic>> editFileMetadata(
String filePath,
Map<String, String> metadata,
) async {
final metadataJSON = jsonEncode(metadata);
final result = await _channel.invokeMethod('editFileMetadata', {
'file_path': filePath,
'metadata_json': metadataJSON,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
final result = await _channel.invokeMethod('writeTempToSaf', {
'temp_path': tempPath,
'saf_uri': safUri,
});
final map = jsonDecode(result as String) as Map<String, dynamic>;
return map['success'] == true;
}
static Future<void> startDownloadService({ static Future<void> startDownloadService({
String trackName = '', String trackName = '',
String artistName = '', String artistName = '',
@@ -953,68 +1023,68 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
}); });
} }
/// Scan a folder for audio files and read their metadata /// Scan a folder for audio files and read their metadata
/// Returns a list of track metadata /// Returns a list of track metadata
static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async { static Future<List<Map<String, dynamic>>> scanLibraryFolder(String folderPath) async {
_log.i('scanLibraryFolder: $folderPath'); _log.i('scanLibraryFolder: $folderPath');
final result = await _channel.invokeMethod('scanLibraryFolder', { final result = await _channel.invokeMethod('scanLibraryFolder', {
'folder_path': folderPath, 'folder_path': folderPath,
}); });
final list = jsonDecode(result as String) as List<dynamic>; final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList(); return list.map((e) => e as Map<String, dynamic>).toList();
} }
/// Perform an incremental scan of the library folder
/// Only scans files that are new or have changed since last scan
/// [existingFiles] is a map of filePath -> modTime (unix millis)
/// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
String folderPath,
Map<String, int> existingFiles,
) async {
_log.i('scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)');
final result = await _channel.invokeMethod('scanLibraryFolderIncremental', {
'folder_path': folderPath,
'existing_files': jsonEncode(existingFiles),
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async { /// Perform an incremental scan of the library folder
_log.i('scanSafTree: $treeUri'); /// Only scans files that are new or have changed since last scan
final result = await _channel.invokeMethod('scanSafTree', { /// [existingFiles] is a map of filePath -> modTime (unix millis)
'tree_uri': treeUri, /// Returns IncrementalScanResult with scanned items, deleted paths, and skip count
}); static Future<Map<String, dynamic>> scanLibraryFolderIncremental(
final list = jsonDecode(result as String) as List<dynamic>; String folderPath,
return list.map((e) => e as Map<String, dynamic>).toList(); Map<String, int> existingFiles,
} ) async {
_log.i('scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)');
/// Incremental SAF tree scan - only scans new or modified files final result = await _channel.invokeMethod('scanLibraryFolderIncremental', {
/// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files) 'folder_path': folderPath,
static Future<Map<String, dynamic>> scanSafTreeIncremental( 'existing_files': jsonEncode(existingFiles),
String treeUri, });
Map<String, int> existingFiles, return jsonDecode(result as String) as Map<String, dynamic>;
) async { }
_log.i('scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)');
final result = await _channel.invokeMethod('scanSafTreeIncremental', { static Future<List<Map<String, dynamic>>> scanSafTree(String treeUri) async {
'tree_uri': treeUri, _log.i('scanSafTree: $treeUri');
'existing_files': jsonEncode(existingFiles), final result = await _channel.invokeMethod('scanSafTree', {
}); 'tree_uri': treeUri,
return jsonDecode(result as String) as Map<String, dynamic>; });
} final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
/// Get last-modified timestamps for a list of SAF file URIs. }
/// Returns map uri -> modTime (unix millis), only for files that still exist.
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async { /// Incremental SAF tree scan - only scans new or modified files
final result = await _channel.invokeMethod('getSafFileModTimes', { /// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files)
'uris': jsonEncode(uris), static Future<Map<String, dynamic>> scanSafTreeIncremental(
}); String treeUri,
final map = jsonDecode(result as String) as Map<String, dynamic>; Map<String, int> existingFiles,
return map.map((key, value) => MapEntry(key, (value as num).toInt())); ) async {
} _log.i('scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)');
final result = await _channel.invokeMethod('scanSafTreeIncremental', {
/// Get current library scan progress 'tree_uri': treeUri,
static Future<Map<String, dynamic>> getLibraryScanProgress() async { 'existing_files': jsonEncode(existingFiles),
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
/// Get last-modified timestamps for a list of SAF file URIs.
/// Returns map uri -> modTime (unix millis), only for files that still exist.
static Future<Map<String, int>> getSafFileModTimes(List<String> uris) async {
final result = await _channel.invokeMethod('getSafFileModTimes', {
'uris': jsonEncode(uris),
});
final map = jsonDecode(result as String) as Map<String, dynamic>;
return map.map((key, value) => MapEntry(key, (value as num).toInt()));
}
/// Get current library scan progress
static Future<Map<String, dynamic>> getLibraryScanProgress() async {
final result = await _channel.invokeMethod('getLibraryScanProgress'); final result = await _channel.invokeMethod('getLibraryScanProgress');
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
@@ -1117,4 +1187,67 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
_log.d('clearStoreCache'); _log.d('clearStoreCache');
await _channel.invokeMethod('clearStoreCache'); await _channel.invokeMethod('clearStoreCache');
} }
// ==================== YOUTUBE / COBALT ====================
/// Download a track from YouTube using the Cobalt API.
/// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps).
/// It does NOT participate in the lossless fallback chain.
static Future<Map<String, dynamic>> downloadFromYouTube({
required String trackName,
required String artistName,
required String albumName,
String? albumArtist,
String? coverUrl,
required String outputDir,
required String filenameFormat,
String quality = 'opus_256',
int trackNumber = 1,
int discNumber = 1,
String? releaseDate,
String? itemId,
int durationMs = 0,
String? isrc,
String? spotifyId,
String? deezerId,
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)');
final request = jsonEncode({
'track_name': trackName,
'artist_name': artistName,
'album_name': albumName,
'album_artist': albumArtist ?? artistName,
'cover_url': coverUrl,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'track_number': trackNumber,
'disc_number': discNumber,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
'duration_ms': durationMs,
'isrc': isrc ?? '',
'spotify_id': spotifyId ?? '',
'deezer_id': deezerId ?? '',
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadFromYouTube', request);
final response = jsonDecode(result as String) as Map<String, dynamic>;
if (response['success'] == true) {
_log.i('YouTube download success: ${response['file_path']}');
} else {
_log.w('YouTube download failed: ${response['error']}');
}
return response;
}
} }
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
const double kNormalizedHeaderTopPadding = 24.0;
double normalizedHeaderTopPadding(
BuildContext context, {
double max = kNormalizedHeaderTopPadding,
}) {
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
return 0;
}
final topPadding = MediaQuery.paddingOf(context).top;
if (topPadding <= 0) return 0;
return topPadding > max ? max : topPadding;
}
+129
View File
@@ -1,9 +1,138 @@
import 'dart:io'; import 'dart:io';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
/// Regular expression to detect iOS app container paths.
/// Matches paths like /var/mobile/Containers/Data/Application/{UUID}
/// or /private/var/mobile/Containers/Data/Application/{UUID}
final _iosContainerRootPattern = RegExp(
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
caseSensitive: false,
);
/// Checks if a path is a valid writable directory on iOS.
/// Returns false if:
/// - The path is the app container root (not writable)
/// - The path is an iCloud Drive path (not accessible by Go backend)
/// - The path is outside the app sandbox
bool isValidIosWritablePath(String path) {
if (!Platform.isIOS) return true;
if (path.isEmpty) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
return false;
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
);
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
// Valid paths should have something after the UUID
if (remainingPath.isEmpty || remainingPath == '/') {
return false;
}
}
return true;
}
/// Validates and potentially corrects an iOS path.
/// Returns a valid Documents subdirectory path if the input is invalid.
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async {
if (!Platform.isIOS) return path;
if (isValidIosWritablePath(path)) {
return path;
}
// Fall back to app Documents directory
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/$subfolder');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
return musicDir.path;
}
/// Detailed result for iOS path validation
class IosPathValidationResult {
final bool isValid;
final String? correctedPath;
final String? errorReason;
const IosPathValidationResult({
required this.isValid,
this.correctedPath,
this.errorReason,
});
}
/// Validates an iOS path and returns detailed information about the result.
IosPathValidationResult validateIosPath(String path) {
if (!Platform.isIOS) {
return const IosPathValidationResult(isValid: true);
}
if (path.isEmpty) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Path is empty',
);
}
// Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.',
);
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'iCloud Drive is not supported. Please choose a local folder.',
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
);
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
if (remainingPath.isEmpty || remainingPath == '/') {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.',
);
}
}
return const IosPathValidationResult(isValid: true);
}
class FileAccessStat { class FileAccessStat {
final int? size; final int? size;
final DateTime? modified; final DateTime? modified;
+89 -49
View File
@@ -7,6 +7,15 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
const int _maxLogMessageLength = 500;
String _truncateLogText(String value, {int maxLength = _maxLogMessageLength}) {
if (value.length <= maxLength) {
return value;
}
return '${value.substring(0, maxLength)}...[truncated]';
}
class LogEntry { class LogEntry {
final DateTime timestamp; final DateTime timestamp;
final String level; final String level;
@@ -46,10 +55,11 @@ class LogBuffer extends ChangeNotifier {
LogBuffer._internal(); LogBuffer._internal();
static const int maxEntries = 500; static const int maxEntries = 500;
static const Duration _goLogPollingInterval = Duration(milliseconds: 800);
final Queue<LogEntry> _entries = Queue<LogEntry>(); final Queue<LogEntry> _entries = Queue<LogEntry>();
Timer? _goLogTimer; Timer? _goLogTimer;
int _lastGoLogIndex = 0; int _lastGoLogIndex = 0;
static bool _loggingEnabled = false; static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled; static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) { static set loggingEnabled(bool value) {
@@ -68,17 +78,33 @@ class LogBuffer extends ChangeNotifier {
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') { if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
return; return;
} }
final sanitizedMessage = _truncateLogText(entry.message);
final sanitizedError = entry.error != null
? _truncateLogText(entry.error!)
: null;
final sanitizedEntry =
(sanitizedMessage == entry.message && sanitizedError == entry.error)
? entry
: LogEntry(
timestamp: entry.timestamp,
level: entry.level,
tag: entry.tag,
message: sanitizedMessage,
error: sanitizedError,
isFromGo: entry.isFromGo,
);
if (_entries.length >= maxEntries) { if (_entries.length >= maxEntries) {
_entries.removeFirst(); _entries.removeFirst();
} }
_entries.add(entry); _entries.add(sanitizedEntry);
notifyListeners(); notifyListeners();
} }
void startGoLogPolling() { void startGoLogPolling() {
_goLogTimer?.cancel(); _goLogTimer?.cancel();
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async { _goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
await _fetchGoLogs(); await _fetchGoLogs();
}); });
} }
@@ -93,13 +119,13 @@ class LogBuffer extends ChangeNotifier {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex); final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
final logs = result['logs'] as List<dynamic>? ?? []; final logs = result['logs'] as List<dynamic>? ?? [];
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
for (final log in logs) { for (final log in logs) {
final timestamp = log['timestamp'] as String? ?? ''; final timestamp = log['timestamp'] as String? ?? '';
final level = log['level'] as String? ?? 'INFO'; final level = log['level'] as String? ?? 'INFO';
final tag = log['tag'] as String? ?? 'Go'; final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? ''; final message = log['message'] as String? ?? '';
DateTime parsedTime = DateTime.now(); DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) { if (timestamp.isNotEmpty) {
try { try {
@@ -107,25 +133,29 @@ class LogBuffer extends ChangeNotifier {
if (parts.length >= 3) { if (parts.length >= 3) {
final secParts = parts[2].split('.'); final secParts = parts[2].split('.');
parsedTime = DateTime( parsedTime = DateTime(
parsedTime.year, parsedTime.month, parsedTime.day, parsedTime.year,
int.parse(parts[0]), int.parse(parts[1]), parsedTime.month,
parsedTime.day,
int.parse(parts[0]),
int.parse(parts[1]),
int.parse(secParts[0]), int.parse(secParts[0]),
secParts.length > 1 ? int.parse(secParts[1]) : 0, secParts.length > 1 ? int.parse(secParts[1]) : 0,
); );
} }
} catch (_) { } catch (_) {}
}
} }
add(LogEntry( add(
timestamp: parsedTime, LogEntry(
level: level, timestamp: parsedTime,
tag: tag, level: level,
message: message, tag: tag,
isFromGo: true, message: message,
)); isFromGo: true,
),
);
} }
_lastGoLogIndex = nextIndex; _lastGoLogIndex = nextIndex;
} catch (e) { } catch (e) {
if (kDebugMode) { if (kDebugMode) {
@@ -156,27 +186,31 @@ class LogBuffer extends ChangeNotifier {
Future<String> exportWithDeviceInfo() async { Future<String> exportWithDeviceInfo() async {
final buffer = StringBuffer(); final buffer = StringBuffer();
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln('SPOTIFLAC LOG EXPORT'); buffer.writeln('SPOTIFLAC LOG EXPORT');
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln(); buffer.writeln();
buffer.writeln('--- App Information ---'); buffer.writeln('--- App Information ---');
buffer.writeln('App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})'); buffer.writeln(
'App Version: ${AppInfo.version} (Build ${AppInfo.buildNumber})',
);
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}'); buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
buffer.writeln(); buffer.writeln();
buffer.writeln('--- Device Information ---'); buffer.writeln('--- Device Information ---');
try { try {
final deviceInfo = DeviceInfoPlugin(); final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) { if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo; final android = await deviceInfo.androidInfo;
buffer.writeln('Platform: Android'); buffer.writeln('Platform: Android');
buffer.writeln('Device: ${android.manufacturer} ${android.model}'); buffer.writeln('Device: ${android.manufacturer} ${android.model}');
buffer.writeln('Brand: ${android.brand}'); buffer.writeln('Brand: ${android.brand}');
buffer.writeln('Android Version: ${android.version.release} (SDK ${android.version.sdkInt})'); buffer.writeln(
'Android Version: ${android.version.release} (SDK ${android.version.sdkInt})',
);
buffer.writeln('Device ID: ${android.id}'); buffer.writeln('Device ID: ${android.id}');
buffer.writeln('Hardware: ${android.hardware}'); buffer.writeln('Hardware: ${android.hardware}');
buffer.writeln('Product: ${android.product}'); buffer.writeln('Product: ${android.product}');
@@ -196,16 +230,16 @@ class LogBuffer extends ChangeNotifier {
buffer.writeln('Failed to get device info: $e'); buffer.writeln('Failed to get device info: $e');
} }
buffer.writeln(); buffer.writeln();
buffer.writeln('--- Log Summary ---'); buffer.writeln('--- Log Summary ---');
buffer.writeln('Total Entries: ${_entries.length}'); buffer.writeln('Total Entries: ${_entries.length}');
int errorCount = 0; int errorCount = 0;
int warnCount = 0; int warnCount = 0;
int infoCount = 0; int infoCount = 0;
int debugCount = 0; int debugCount = 0;
int goCount = 0; int goCount = 0;
for (final entry in _entries) { for (final entry in _entries) {
switch (entry.level) { switch (entry.level) {
case 'ERROR': case 'ERROR':
@@ -224,23 +258,23 @@ class LogBuffer extends ChangeNotifier {
} }
if (entry.isFromGo) goCount++; if (entry.isFromGo) goCount++;
} }
buffer.writeln('Errors: $errorCount'); buffer.writeln('Errors: $errorCount');
buffer.writeln('Warnings: $warnCount'); buffer.writeln('Warnings: $warnCount');
buffer.writeln('Info: $infoCount'); buffer.writeln('Info: $infoCount');
buffer.writeln('Debug: $debugCount'); buffer.writeln('Debug: $debugCount');
buffer.writeln('From Go Backend: $goCount'); buffer.writeln('From Go Backend: $goCount');
buffer.writeln(); buffer.writeln();
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln('LOG ENTRIES'); buffer.writeln('LOG ENTRIES');
buffer.writeln('=' * 60); buffer.writeln('=' * 60);
buffer.writeln(); buffer.writeln();
for (final entry in _entries) { for (final entry in _entries) {
buffer.writeln(entry.toString()); buffer.writeln(entry.toString());
} }
return buffer.toString(); return buffer.toString();
} }
@@ -274,19 +308,21 @@ class BufferedOutput extends LogOutput {
void output(OutputEvent event) { void output(OutputEvent event) {
if (kDebugMode) { if (kDebugMode) {
for (final line in event.lines) { for (final line in event.lines) {
debugPrint(line); debugPrint(_truncateLogText(line));
} }
} }
final level = _levelToString(event.level); final level = _levelToString(event.level);
final message = event.lines.join('\n'); final message = _truncateLogText(event.lines.join('\n'));
LogBuffer().add(LogEntry( LogBuffer().add(
timestamp: DateTime.now(), LogEntry(
level: level, timestamp: DateTime.now(),
tag: tag, level: level,
message: message, tag: tag,
)); message: message,
),
);
} }
String _levelToString(Level level) { String _levelToString(Level level) {
@@ -336,13 +372,15 @@ class AppLogger {
} }
void _addToBuffer(String level, String message, {String? error}) { void _addToBuffer(String level, String message, {String? error}) {
LogBuffer().add(LogEntry( LogBuffer().add(
timestamp: DateTime.now(), LogEntry(
level: level, timestamp: DateTime.now(),
tag: _tag, level: level,
message: message, tag: _tag,
error: error, message: message,
)); error: error,
),
);
} }
void d(String message) { void d(String message) {
@@ -373,7 +411,9 @@ class AppLogger {
if (error != null) { if (error != null) {
_addToBuffer('ERROR', message, error: error.toString()); _addToBuffer('ERROR', message, error: error.toString());
if (kDebugMode) { if (kDebugMode) {
debugPrint('[$_tag] ERROR: $message | $error'); debugPrint(
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}',
);
if (stackTrace != null) { if (stackTrace != null) {
debugPrint(stackTrace.toString()); debugPrint(stackTrace.toString());
} }
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// A collapsing header widget /// A collapsing header widget
/// Title collapses from large to small when scrolling /// Title collapses from large to small when scrolling
@@ -19,7 +20,7 @@ class CollapsingHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
+25 -137
View File
@@ -22,7 +22,9 @@ class BuiltInService {
}); });
} }
/// Default quality options for built-in services (Tidal, Qobuz, Amazon) /// Default quality options for built-in services (Tidal, Qobuz, YouTube)
/// Note: Amazon is fallback-only and not shown in picker
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
const _builtInServices = [ const _builtInServices = [
BuiltInService( BuiltInService(
id: 'tidal', id: 'tidal',
@@ -31,7 +33,6 @@ const _builtInServices = [
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'),
], ],
), ),
BuiltInService( BuiltInService(
@@ -44,15 +45,14 @@ const _builtInServices = [
], ],
), ),
BuiltInService( BuiltInService(
id: 'amazon', id: 'youtube',
label: 'Amazon', label: 'YouTube',
qualityOptions: [ qualityOptions: [
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'),
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
], ],
isDisabled: true, isDisabled: false,
disabledReason: 'Fallback only', disabledReason: null,
), ),
]; ];
@@ -211,7 +211,7 @@ Padding(
), ),
), ),
if (_builtInServices.any((s) => s.id == _selectedService)) if (_builtInServices.any((s) => s.id == _selectedService && s.id != 'youtube'))
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text( child: Text(
@@ -223,19 +223,26 @@ Padding(
), ),
), ),
if (_selectedService == 'youtube')
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.youtubeQualityNote,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
for (final quality in qualityOptions) for (final quality in qualityOptions)
_QualityOption( _QualityOption(
title: quality.label, title: quality.label,
subtitle: quality.description ?? '', subtitle: quality.description ?? '',
icon: _getQualityIcon(quality.id), icon: _getQualityIcon(quality.id),
onTap: () { onTap: () {
// For Tidal HIGH quality, show format picker first Navigator.pop(context);
if (_selectedService == 'tidal' && quality.id == 'HIGH') { widget.onSelect(quality.id, _selectedService);
_showLossyFormatPicker(context);
} else {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
}
}, },
), ),
@@ -254,136 +261,17 @@ Padding(
return Icons.high_quality; return Icons.high_quality;
case 'LOSSLESS': case 'LOSSLESS':
return Icons.music_note; return Icons.music_note;
case 'HIGH':
return Icons.aod;
case 'MP3_320': case 'MP3_320':
case 'MP3': case 'MP3':
return Icons.audiotrack; return Icons.audiotrack;
case 'OPUS': case 'OPUS':
case 'OPUS_128': case 'OPUS_128':
case 'OPUS_256':
return Icons.graphic_eq; return Icons.graphic_eq;
default: default:
return Icons.music_note; return Icons.music_note;
} }
} }
void _showLossyFormatPicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
final currentFormat = settings.tidalHighFormat;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (modalContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Select Lossy Format',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose output format for 320kbps lossy download',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: currentFormat == 'mp3_320'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 256kbps'),
subtitle: const Text('Best quality Opus, ~8MB per track'),
trailing: currentFormat == 'opus_256'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 128kbps'),
subtitle: const Text('Smallest size, ~4MB per track'),
trailing: currentFormat == 'opus_128'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
const SizedBox(height: 16),
],
),
),
);
}
} }
+28 -36
View File
@@ -189,10 +189,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: connectivity_plus name: connectivity_plus
sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5" version: "7.0.0"
connectivity_plus_platform_interface: connectivity_plus_platform_interface:
dependency: transitive dependency: transitive
description: description:
@@ -386,34 +386,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_local_notifications name: flutter_local_notifications
sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "19.5.0" version: "20.0.0"
flutter_local_notifications_linux: flutter_local_notifications_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_linux name: flutter_local_notifications_linux
sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "7.0.0"
flutter_local_notifications_platform_interface: flutter_local_notifications_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_platform_interface name: flutter_local_notifications_platform_interface
sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.1.0" version: "10.0.0"
flutter_local_notifications_windows: flutter_local_notifications_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_local_notifications_windows name: flutter_local_notifications_windows
sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.3" version: "2.0.0"
flutter_localizations: flutter_localizations:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -439,50 +439,50 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_secure_storage name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.2.4" version: "10.0.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
name: flutter_secure_storage_darwin
sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3"
url: "https://pub.dev"
source: hosted
version: "0.2.0"
flutter_secure_storage_linux: flutter_secure_storage_linux:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_linux name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.3" version: "3.0.0"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface: flutter_secure_storage_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_platform_interface name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "2.0.1"
flutter_secure_storage_web: flutter_secure_storage_web:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_web name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.1" version: "2.1.0"
flutter_secure_storage_windows: flutter_secure_storage_windows:
dependency: transitive dependency: transitive
description: description:
name: flutter_secure_storage_windows name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "4.1.0"
flutter_svg: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -741,14 +741,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
palette_generator:
dependency: "direct main"
description:
name: palette_generator
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
url: "https://pub.dev"
source: hosted
version: "0.3.3+7"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
+4 -5
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.5.0+74 version: 3.6.0+77
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0
@@ -24,7 +24,7 @@ dependencies:
# Storage & Persistence # Storage & Persistence
shared_preferences: ^2.5.3 shared_preferences: ^2.5.3
flutter_secure_storage: ^9.2.2 flutter_secure_storage: 10.0.0
path_provider: ^2.1.5 path_provider: ^2.1.5
path: ^1.9.0 path: ^1.9.0
sqflite: ^2.4.1 sqflite: ^2.4.1
@@ -32,7 +32,7 @@ dependencies:
# HTTP & Network # HTTP & Network
http: ^1.6.0 http: ^1.6.0
dio: ^5.8.0 dio: ^5.8.0
connectivity_plus: ^6.0.3 connectivity_plus: 7.0.0
# UI Components # UI Components
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
@@ -43,7 +43,6 @@ dependencies:
# Material Expressive 3 / Dynamic Color # Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1 material_color_utilities: ^0.11.1
palette_generator: ^0.3.3+4
# Permissions # Permissions
permission_handler: ^12.0.1 permission_handler: ^12.0.1
@@ -66,7 +65,7 @@ dependencies:
open_filex: ^4.7.0 open_filex: ^4.7.0
# Notifications # Notifications
flutter_local_notifications: ^19.0.0 flutter_local_notifications: 20.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"baseBranches": ["dev"],
"ignoreDeps": ["dev.flutter.flutter-plugin-loader"],
"automerge": true,
"automergeType": "pr",
"packageRules": [
{
"matchUpdateTypes": ["major"],
"automerge": false
},
{
"matchManagers": ["pub"],
"groupName": "Flutter dependencies"
},
{
"matchManagers": ["gomod"],
"groupName": "Go dependencies"
},
{
"matchManagers": ["gradle"],
"groupName": "Gradle dependencies"
}
]
}