mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 03:37:56 +02:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a6b7eed59 | |||
| 51d02d7764 | |||
| df39d61ed4 | |||
| 7ec5d28caf | |||
| 23f5aa11b0 | |||
| 5fdf1df5df | |||
| f9dd82010f | |||
| f0790b627d | |||
| 55350fffa0 | |||
| 7229602343 | |||
| 1c81c53699 | |||
| 5256d6197b | |||
| 79a6c8cdc0 | |||
| aa3b4d7d1e | |||
| cd220a4650 | |||
| d71b2a9ab8 | |||
| a2efe7243d | |||
| e0acda14e4 | |||
| 029ab8ea47 | |||
| 38f9498006 | |||
| 67fc3e5de2 | |||
| f1e6e9253f | |||
| 11c612e270 | |||
| cec5e49659 | |||
| 1dbdb5f2c3 | |||
| 086511d3e9 | |||
| 3d366d21b7 | |||
| 35f412dbd2 | |||
| c167aa0522 | |||
| fccb3f3d78 | |||
| 3a33283e94 | |||
| c74fb28a3a | |||
| ea504cc3ed | |||
| 61a2ad258e | |||
| ab62a8b1a9 | |||
| 479eb1272d | |||
| d23562e579 | |||
| 541d64bdd0 | |||
| d4f7e6e494 | |||
| 532c08fe2e | |||
| 704b9674f4 | |||
| 3de94280d2 | |||
| 65897789f6 | |||
| 5d097c3a95 | |||
| 4023e752a0 | |||
| 9a722b1a24 | |||
| 37b4727a29 | |||
| 2604d0002a | |||
| cca337ab31 | |||
| bb6e766a09 | |||
| af203ae51f | |||
| 01cbdde70e | |||
| e70ed311ed | |||
| c732cddf06 | |||
| 1f71f957e2 | |||
| 757c5fab19 | |||
| cfa537db1f | |||
| 6e7c766945 | |||
| 55b457a4c0 | |||
| 423695c24d |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
|
[](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
|
||||||
[](https://crowdin.com/project/spotiflac-mobile)
|
[](https://crowdin.com/project/spotiflac-mobile)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
@@ -24,15 +24,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Search Source
|
|
||||||
|
|
||||||
SpotiFLAC supports multiple search sources for finding music metadata:
|
|
||||||
|
|
||||||
| Source | Setup |
|
|
||||||
|--------|-------|
|
|
||||||
| **Deezer** (Default) | No setup required |
|
|
||||||
| **Extensions** | Install additional search providers from the Store |
|
|
||||||
|
|
||||||
## Extensions
|
## Extensions
|
||||||
|
|
||||||
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
|
||||||
@@ -54,15 +45,8 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
|
|||||||
|
|
||||||
## Telegram
|
## Telegram
|
||||||
|
|
||||||
<p align="center">
|
[](https://t.me/spotiflac)
|
||||||
<a href="https://t.me/spotiflac">
|
[](https://t.me/spotiflac_chat)
|
||||||
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://t.me/spotiflac_chat">
|
|
||||||
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
@@ -108,6 +92,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 ~
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2911
-2681
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+662
-356
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+307
-142
@@ -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
@@ -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
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
final logs = _filteredLogs;
|
final logs = _filteredLogs;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
|
|
||||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||||
const MetadataProviderPriorityPage({super.key});
|
const MetadataProviderPriorityPage({super.key});
|
||||||
@@ -40,7 +41,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: !_hasChanges,
|
canPop: !_hasChanges,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/models/settings.dart';
|
|||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class OptionsSettingsPage extends ConsumerWidget {
|
class OptionsSettingsPage extends ConsumerWidget {
|
||||||
@@ -16,7 +17,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
final extensionState = ref.watch(extensionProvider);
|
final extensionState = ref.watch(extensionProvider);
|
||||||
final hasExtensions = extensionState.extensions.isNotEmpty;
|
final hasExtensions = extensionState.extensions.isNotEmpty;
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = normalizedHeaderTopPadding(context);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: true, // Always allow back gesture
|
canPop: true, // Always allow back gesture
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -16,6 +16,26 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
static const int _totalPages = 6;
|
static const int _totalPages = 6;
|
||||||
|
|
||||||
|
double _responsiveScale({
|
||||||
|
required BuildContext context,
|
||||||
|
double min = 0.82,
|
||||||
|
double max = 1.08,
|
||||||
|
double baseShortestSide = 390,
|
||||||
|
}) {
|
||||||
|
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||||
|
final scale = shortestSide / baseShortestSide;
|
||||||
|
if (scale < min) return min;
|
||||||
|
if (scale > max) return max;
|
||||||
|
return scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _effectiveTextScale(BuildContext context) {
|
||||||
|
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||||
|
if (textScale < 1.0) return 1.0;
|
||||||
|
if (textScale > 1.4) return 1.4;
|
||||||
|
return textScale;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_pageController.dispose();
|
_pageController.dispose();
|
||||||
@@ -55,6 +75,15 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final isLastPage = _currentPage == _totalPages - 1;
|
final isLastPage = _currentPage == _totalPages - 1;
|
||||||
|
final scale = _responsiveScale(context: context, min: 0.86, max: 1.05);
|
||||||
|
final textScale = _effectiveTextScale(context);
|
||||||
|
final topBarPaddingH = 24 * scale;
|
||||||
|
final topBarPaddingV = 16 * scale;
|
||||||
|
final pageIndicatorHeight = 8 * scale;
|
||||||
|
final pageIndicatorWidth = 8 * scale;
|
||||||
|
final activeIndicatorWidth = 32 * scale;
|
||||||
|
final bottomGap = (32 * scale) + ((textScale - 1) * 8);
|
||||||
|
final actionButtonHeight = (56 * scale) + ((textScale - 1) * 6);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
@@ -63,7 +92,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||||||
children: [
|
children: [
|
||||||
// Top Navigation Bar
|
// Top Navigation Bar
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: topBarPaddingH,
|
||||||
|
vertical: topBarPaddingV,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
@@ -199,9 +231,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeOutBack,
|
curve: Curves.easeOutBack,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
margin: EdgeInsets.symmetric(horizontal: 4 * scale),
|
||||||
height: 8,
|
height: pageIndicatorHeight,
|
||||||
width: isActive ? 32 : 8,
|
width: isActive
|
||||||
|
? activeIndicatorWidth
|
||||||
|
: pageIndicatorWidth,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isActive
|
color: isActive
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
@@ -211,11 +245,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
SizedBox(height: bottomGap),
|
||||||
// Action Button
|
// Action Button
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 56,
|
height: actionButtonHeight,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: _nextPage,
|
onPressed: _nextPage,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -520,104 +554,114 @@ class _InteractiveDownloadExampleState
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return Container(
|
return LayoutBuilder(
|
||||||
padding: const EdgeInsets.all(20),
|
builder: (context, constraints) {
|
||||||
decoration: BoxDecoration(
|
final cardWidth = constraints.maxWidth;
|
||||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
|
||||||
borderRadius: BorderRadius.circular(28),
|
final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
|
||||||
border: Border.all(
|
final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
|
||||||
),
|
return Container(
|
||||||
),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
Container(
|
borderRadius: BorderRadius.circular(28),
|
||||||
width: 72,
|
border: Border.all(
|
||||||
height: 72,
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.album_rounded,
|
|
||||||
size: 36,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
child: Row(
|
||||||
Expanded(
|
children: [
|
||||||
child: Column(
|
Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
width: coverSize,
|
||||||
children: [
|
height: coverSize,
|
||||||
Container(
|
decoration: BoxDecoration(
|
||||||
width: 140,
|
color: colorScheme.primaryContainer,
|
||||||
height: 14,
|
borderRadius: BorderRadius.circular(20),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: colorScheme.onSurface,
|
child: Icon(
|
||||||
borderRadius: BorderRadius.circular(7),
|
Icons.album_rounded,
|
||||||
),
|
size: coverSize * 0.5,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
|
||||||
if (_isDownloading)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: LinearProgressIndicator(
|
|
||||||
value: _progress,
|
|
||||||
minHeight: 12,
|
|
||||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Container(
|
|
||||||
width: 90,
|
|
||||||
height: 12,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _startDownload,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _isCompleted ? Colors.green : colorScheme.primary,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: (_isCompleted ? Colors.green : colorScheme.primary)
|
|
||||||
.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: _isDownloading
|
const SizedBox(width: 20),
|
||||||
? SizedBox(
|
Expanded(
|
||||||
width: 28,
|
child: Column(
|
||||||
height: 28,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: CircularProgressIndicator(
|
children: [
|
||||||
strokeWidth: 3,
|
Container(
|
||||||
color: colorScheme.onPrimary,
|
width: (cardWidth * 0.35).clamp(100.0, 160.0),
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
_isCompleted
|
|
||||||
? Icons.check_rounded
|
|
||||||
: Icons.download_rounded,
|
|
||||||
color: colorScheme.onPrimary,
|
|
||||||
size: 28,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
|
if (_isDownloading)
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: _progress,
|
||||||
|
minHeight: 12,
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
width: (cardWidth * 0.22).clamp(70.0, 100.0),
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _startDownload,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
padding: EdgeInsets.all(buttonPadding),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isCompleted ? Colors.green : colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color:
|
||||||
|
(_isCompleted ? Colors.green : colorScheme.primary)
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: _isDownloading
|
||||||
|
? SizedBox(
|
||||||
|
width: buttonIconSize,
|
||||||
|
height: buttonIconSize,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_isCompleted
|
||||||
|
? Icons.check_rounded
|
||||||
|
: Icons.download_rounded,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: buttonIconSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -644,6 +688,18 @@ class _TutorialPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||||
|
final textScale = MediaQuery.textScalerOf(
|
||||||
|
context,
|
||||||
|
).scale(1.0).clamp(1.0, 1.4);
|
||||||
|
final scale = (shortestSide / 390).clamp(0.86, 1.05);
|
||||||
|
final topGap = (24 * scale).clamp(16.0, 24.0);
|
||||||
|
final iconPadding = (24 * scale).clamp(18.0, 24.0);
|
||||||
|
final iconSize = (56 * scale).clamp(44.0, 56.0);
|
||||||
|
final iconTextGap = (48 * scale).clamp(28.0, 48.0);
|
||||||
|
final descriptionGap = (20 * scale).clamp(12.0, 20.0);
|
||||||
|
final contentGap = (56 * scale) + ((textScale - 1) * 10);
|
||||||
|
final bottomGap = (32 * scale).clamp(20.0, 32.0);
|
||||||
|
|
||||||
// Parallax effect logic (simplified for StatelessWidget)
|
// Parallax effect logic (simplified for StatelessWidget)
|
||||||
// In a real advanced implementation we'd pass the Controller's listenable
|
// In a real advanced implementation we'd pass the Controller's listenable
|
||||||
@@ -656,23 +712,23 @@ class _TutorialPage extends StatelessWidget {
|
|||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
SizedBox(height: topGap),
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
curve: Curves.easeOutBack,
|
curve: Curves.easeOutBack,
|
||||||
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
|
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
|
||||||
padding: const EdgeInsets.all(24),
|
padding: EdgeInsets.all(iconPadding),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
|
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 56,
|
size: iconSize,
|
||||||
color: iconColor ?? colorScheme.primary,
|
color: iconColor ?? colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
SizedBox(height: iconTextGap),
|
||||||
AnimatedOpacity(
|
AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
opacity: isActive ? 1.0 : 0.0,
|
opacity: isActive ? 1.0 : 0.0,
|
||||||
@@ -687,7 +743,7 @@ class _TutorialPage extends StatelessWidget {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
SizedBox(height: descriptionGap),
|
||||||
AnimatedOpacity(
|
AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 500),
|
duration: const Duration(milliseconds: 500),
|
||||||
opacity: isActive ? 1.0 : 0.0,
|
opacity: isActive ? 1.0 : 0.0,
|
||||||
@@ -697,14 +753,14 @@ class _TutorialPage extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
fontSize: 16,
|
fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 56),
|
SizedBox(height: contentGap),
|
||||||
content, // The content itself now handles its own internal animations
|
content, // The content itself now handles its own internal animations
|
||||||
const SizedBox(height: 32),
|
SizedBox(height: bottomGap),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user