Compare commits

..

77 Commits

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

- Wrap popular track items in Consumer for scoped provider watches

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

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

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

- Memoize filter/sort computations per build pass

- Isolate queue header/list into dedicated Consumer slivers

- Fix Positioned/ValueListenableBuilder nesting order in grid view
2026-02-08 14:20:18 +07:00
zarzet 7229602343 feat: replace date filter with sorting (latest/oldest/A-Z/Z-A)
- Remove broken date range filter (today/week/month/year)
- Add sort options: Latest, Oldest, A-Z, Z-A
- Sorting applies to tracks (all/singles tabs) and albums tab
- Add l10n keys for sort labels
2026-02-08 13:44:02 +07:00
zarzet 1c81c53699 fix: library filters now apply to date/albums and update tab counts
- Remove redundant manual export button from queue header
- Add date range filtering support for local library items
- Apply advanced filters (date, quality, format, source) to album tab
- Tab chip counts (All/Albums/Singles) now reflect filtered results
- Extract reusable filter helpers: _passesDateFilter, _passesQualityFilter, _passesFormatFilter
- Add _filterGroupedAlbums and _filterGroupedLocalAlbums methods
2026-02-08 13:09:19 +07:00
zarzet 5256d6197b fix: metadata enrichment bug and upgrade go-flac to v2
- Fix metadata enrichment bug where failed downloads poison connection pool
  - Create separate metadataTransport for Deezer API calls
  - Add immediate connection cleanup after download failures
- Fix Samsung One UI local library scan with MediaStore fallback
- Fix 'In Library' tracks still showing as downloadable
- Upgrade go-flac packages to v2 (flacpicture v2.0.2, flacvorbis v2.0.2, go-flac v2.0.4)
- Update CHANGELOG.md v3.5.2
2026-02-08 12:01:08 +07:00
Zarz Eleutherius 79a6c8cdc0 Merge pull request #139 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-08 08:31:29 +07:00
renovate[bot] aa3b4d7d1e fix(deps): update go dependencies to v2 2026-02-07 21:39:25 +00:00
zarzet cd220a4650 merge: sync main into dev (README updates) 2026-02-08 02:51:05 +07:00
Zarz Eleutherius d71b2a9ab8 Update README to remove Search Source and enhance Telegram links 2026-02-08 02:48:29 +07:00
zarzet a2efe7243d docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:36:15 +07:00
zarzet e0acda14e4 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:33:56 +07:00
Zarz Eleutherius 029ab8ea47 Update VirusTotal badge link in README 2026-02-08 02:30:22 +07:00
zarzet 38f9498006 docs: add API credits to README and SpotiSaver to about page 2026-02-08 02:26:27 +07:00
zarzet 67fc3e5de2 fix: revert AGP 9 to 8.13.2 - Flutter plugins not yet compatible with AGP 9 2026-02-07 20:46:23 +07:00
zarzet f1e6e9253f fix: opt out of AGP 9 newDsl for Flutter compatibility 2026-02-07 20:26:59 +07:00
zarzet 11c612e270 fix: remove kotlin-android plugin for AGP 9 built-in Kotlin support 2026-02-07 20:12:26 +07:00
zarzet cec5e49659 fix(deps): migrate flutter_local_notifications to v20 named params, update changelog with all dependency changes since 3.5.0 2026-02-07 20:02:11 +07:00
Zarz Eleutherius 1dbdb5f2c3 Update VirusTotal badge link in README 2026-02-07 19:57:44 +07:00
zarzet 086511d3e9 perf: unified parallel scheduler, dynamic concurrency 1-5, log truncation + FFmpeg command redaction 2026-02-07 19:57:44 +07:00
zarzet 3d366d21b7 perf: optimize providers, throttle polling, queued settings save, remove dead screens 2026-02-07 19:57:44 +07:00
zarzet 35f412dbd2 perf: replace PaletteService with blurred cover background, bump v3.5.1 2026-02-07 19:57:44 +07:00
Zarz Eleutherius c167aa0522 Merge pull request #136 from zarzet/renovate/major-go-dependencies
fix(deps): update go dependencies to v2 (major)
2026-02-07 19:56:07 +07:00
Zarz Eleutherius fccb3f3d78 Merge pull request #135 from zarzet/renovate/major-flutter-dependencies
fix(deps): update flutter dependencies (major)
2026-02-07 19:54:49 +07:00
Zarz Eleutherius 3a33283e94 Merge pull request #133 from zarzet/renovate/major-gradle-dependencies
chore(deps): update plugin com.android.application to v9
2026-02-07 19:49:33 +07:00
Zarz Eleutherius c74fb28a3a Merge pull request #131 from zarzet/renovate/actions-setup-java-5.x
chore(deps): update actions/setup-java action to v5
2026-02-07 19:49:18 +07:00
renovate[bot] ea504cc3ed fix(deps): update go dependencies to v2 2026-02-07 12:48:36 +00:00
renovate[bot] 61a2ad258e fix(deps): update flutter dependencies 2026-02-07 12:48:16 +00:00
Zarz Eleutherius ab62a8b1a9 Merge pull request #134 from zarzet/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2
2026-02-07 19:48:04 +07:00
Zarz Eleutherius 479eb1272d Merge pull request #132 from zarzet/renovate/major-github-artifact-actions
chore(deps): update github artifact actions (major)
2026-02-07 19:47:28 +07:00
renovate[bot] d23562e579 chore(deps): update softprops/action-gh-release action to v2 2026-02-07 12:47:07 +00:00
renovate[bot] 541d64bdd0 chore(deps): update plugin com.android.application to v9 2026-02-07 12:47:04 +00:00
renovate[bot] d4f7e6e494 chore(deps): update github artifact actions 2026-02-07 12:47:00 +00:00
renovate[bot] 532c08fe2e chore(deps): update actions/setup-java action to v5 2026-02-07 12:46:56 +00:00
Zarz Eleutherius 704b9674f4 Merge pull request #128 from zarzet/renovate/actions-cache-5.x
chore(deps): update actions/cache action to v5
2026-02-07 19:35:15 +07:00
Zarz Eleutherius 3de94280d2 Merge pull request #129 from zarzet/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2026-02-07 19:34:45 +07:00
Zarz Eleutherius 65897789f6 Merge pull request #130 from zarzet/renovate/actions-setup-go-6.x
chore(deps): update actions/setup-go action to v6
2026-02-07 19:34:29 +07:00
renovate[bot] 5d097c3a95 chore(deps): update actions/setup-go action to v6 2026-02-07 12:32:50 +00:00
renovate[bot] 4023e752a0 chore(deps): update actions/checkout action to v6 2026-02-07 12:32:47 +00:00
Zarz Eleutherius 9a722b1a24 Merge pull request #127 from zarzet/renovate/gradle-dependencies
fix(deps): update gradle dependencies
2026-02-07 19:31:18 +07:00
renovate[bot] 37b4727a29 chore(deps): update actions/cache action to v5 2026-02-07 11:49:57 +00:00
renovate[bot] 2604d0002a fix(deps): update gradle dependencies 2026-02-07 11:49:46 +00:00
Zarz Eleutherius cca337ab31 Merge pull request #125 from zarzet/renovate/go-dependencies
chore(deps): update dependency go to v1.25.7
2026-02-07 18:48:46 +07:00
renovate[bot] bb6e766a09 chore(deps): update dependency go to v1.25.7 2026-02-07 09:14:48 +00:00
Zarz Eleutherius af203ae51f Update VirusTotal badge link in README 2026-02-07 14:44:19 +07:00
zarzet 01cbdde70e Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-02-07 14:39:08 +07:00
Zarz Eleutherius e70ed311ed Merge pull request #123 from zarzet/renovate/com.android.tools-desugar_jdk_libs-2.x
chore(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5
2026-02-07 14:36:30 +07:00
Zarz Eleutherius c732cddf06 Merge pull request #122 from zarzet/renovate/golang.org-x-mobile-digest
chore(deps): update golang.org/x/mobile digest to 1dceadb
2026-02-07 14:36:16 +07:00
zarzet 1f71f957e2 chore: add Renovate config targeting dev branch with automerge 2026-02-07 14:35:37 +07:00
renovate[bot] 757c5fab19 chore(deps): update dependency com.android.tools:desugar_jdk_libs to v2.1.5 2026-02-07 07:32:37 +00:00
renovate[bot] cfa537db1f chore(deps): update golang.org/x/mobile digest to 1dceadb 2026-02-07 07:32:34 +00:00
zarzet 8b18bef5ab feat: add history message to donate notice card, fix l10n_id formatting 2026-02-07 13:53:13 +07:00
zarzet 76b01fb837 fix: SAF file descriptor handling to avoid ParcelFileDescriptor detach warning 2026-02-07 13:52:57 +07:00
zarzet 219ea593dd chore: add l10n strings for incremental scan and orphan cleanup 2026-02-07 13:20:15 +07:00
zarzet 5c54e04b69 feat: cleanup orphaned downloads from history 2026-02-07 13:20:00 +07:00
zarzet bef07b1583 feat: incremental library scan support and force full scan button 2026-02-07 13:19:46 +07:00
zarzet 859762e35c fix(l10n): improve Indonesian wording for orphaned download cleanup 2026-02-07 13:14:13 +07:00
zarzet ca136b8e17 fix: stabilize incremental library scan and fold 3.5.1 into 3.5.0 2026-02-07 13:11:23 +07:00
zarzet 03d29a73f7 feat: donate page - add GitHub Sponsors, custom icons, improved notice card 2026-02-07 12:40:46 +07:00
zarzet c6ee9cda35 fix: resolve Go staticcheck warnings in audio_metadata.go and qobuz.go 2026-02-07 11:58:46 +07:00
zarzet ad3fefac0b fix: skip tutorial for existing users upgrading to 3.5.0
Migration v2: auto-set hasCompletedTutorial=true when isFirstLaunch
is already false (existing users who completed setup before tutorial
feature was added)
2026-02-07 11:55:43 +07:00
zarzet ad606cca53 feat: v3.5.0 - SAF storage, onboarding redesign, library scan fixes
- SAF Storage Access Framework for Android 10+ downloads
- Redesigned Setup/Tutorial screens with Material 3 Expressive
- Library scan hero card now shows real-time scanned count
- Library folder picker uses SAF (no MANAGE_EXTERNAL_STORAGE needed)
- SAF migration prompt for users updating from pre-SAF versions
- Home feed caching, donate page, per-app language support
- Merged 3.6.0-beta.1 changelog entries into 3.5.0
2026-02-07 11:48:37 +07:00
zarzet c0a9cb756f chore: bump version to 3.5.0-beta.1 2026-02-07 08:13:23 +07:00
zarzet 5fa00c0051 feat: v3.5.0 - instant home feed, SAF display path, per-app language
- Cache home feed to SharedPreferences for instant restore on app launch
- Resolve SAF tree URIs to human-readable paths (e.g. /storage/emulated/0/Music)
- Add Android 13+ per-app language support (locale_config.xml)
- Bump version to 3.5.0+73
2026-02-06 21:22:56 +07:00
zarzet 239e073a8c feat: improve SAF file descriptor handling and Android platform compatibility
- Migrate MainActivity from FlutterActivity to FlutterFragmentActivity for SAF picker compatibility
- Add ImpellerAwareFlutterFragment to support Impeller fallback on legacy devices
- Add output_fd support in Go backend for direct file descriptor writes (SAF)
- Add helper functions in output_fd.go for FD-based file operations
- Refactor Tidal/Qobuz/Amazon downloaders to support FD output and skip metadata embedding for SAF (handled by Flutter)
- Add extractQobuzDownloadURLFromBody with unit tests for robust URL parsing
- Add storage mode picker (SAF vs App folder) in download settings for Android
- Fix FFmpeg output path building to avoid same-path conflicts
- Embed metadata to SAF FLAC files via temp file bridge in Flutter
- Upgrade Gradle wrapper to 9.3.1 and add activity-ktx dependency
2026-02-06 18:47:16 +07:00
zarzet 278ebf3472 feat: add Storage Access Framework (SAF) support for Android 10+
- Add SAF tree picker and persistent URI storage in settings
- Implement SAF file operations: exists, delete, stat, copy, create
- Update download pipeline to support SAF content URIs
- Add fallback to app-private storage when SAF write fails
- Support SAF in library scan with DocumentFile traversal
- Add history item repair for missing SAF URIs
- Create file_access.dart utilities for abstracted file operations
- Update Tidal/Qobuz/Amazon/Extensions for SAF-aware output
- Add runPostProcessingV2 API for SAF content URIs
- Update screens (album, artist, queue, track) for SAF awareness

Resolves Android 10+ scoped storage permission issues
2026-02-06 07:09:57 +07:00
zarzet 7ade57e010 perf: optimize all providers for mobile networks with retry logic
- Add retry logic with exponential backoff to all providers (Qobuz, Tidal, Amazon, Deezer)
- Increase API timeouts: 15s → 25s (Qobuz/Tidal/Deezer), 30s (Amazon)
- Extract QobuzID/TidalID directly from SongLink URLs
- Add SongLink lookup strategy before ISRC search in Qobuz
- Cache hit now uses GetTrackByID() directly instead of re-searching
- Pre-warm cache tries SongLink first before direct ISRC search
2026-02-05 09:12:25 +07:00
Zarz Eleutherius 6e7c766945 Fix VirusTotal badge link formatting in README 2026-02-04 18:29:22 +07:00
Zarz Eleutherius 55b457a4c0 Update VirusTotal badge link in README
Updated VirusTotal badge link in README.md.
2026-02-04 18:28:50 +07:00
zarzet 65a152cada fix: persist metadata and download provider priority across app restarts
- Save priority order to SharedPreferences when set
- Load from SharedPreferences on app start, sync to Go backend
- Fixes issue where custom order reverted to default after restart
2026-02-04 17:45:07 +07:00
Zarz Eleutherius 423695c24d Update VirusTotal badge link in README.md 2026-02-01 22:00:38 +07:00
104 changed files with 29356 additions and 11575 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 2 # Need previous commit to compare
+16 -16
View File
@@ -60,23 +60,23 @@ jobs:
df -h
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: "temurin"
java-version: "17"
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.25"
cache-dependency-path: go_backend/go.sum
# Cache Gradle for faster builds
- name: Cache Gradle
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: |
~/.gradle/caches
@@ -158,7 +158,7 @@ jobs:
ls -la
- name: Upload APK artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: android-apk
path: build/app/outputs/flutter-apk/SpotiFLAC-*.apk
@@ -169,17 +169,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: "1.25"
cache-dependency-path: go_backend/go.sum
# Cache CocoaPods
- name: Cache CocoaPods
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ios/Pods
key: pods-${{ runner.os }}-${{ hashFiles('ios/Podfile.lock') }}
@@ -295,7 +295,7 @@ jobs:
fi
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: ios-ipa
path: build/ios/ipa/SpotiFLAC-*.ipa
@@ -308,7 +308,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Extract changelog for version
id: changelog
@@ -338,13 +338,13 @@ jobs:
cat /tmp/changelog.txt
- name: Download Android APK
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
@@ -385,7 +385,7 @@ jobs:
cat /tmp/release_body.txt
- name: Create Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.get-version.outputs.version }}
name: SpotiFLAC ${{ needs.get-version.outputs.version }}
@@ -403,16 +403,16 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Download Android APK
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: android-apk
path: ./release
- name: Download iOS IPA
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: ios-ipa
path: ./release
+299 -3
View File
@@ -1,5 +1,301 @@
# 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
### Highlights
- **SAF Storage (Android 10+)**: Proper Storage Access Framework support for download destination (content URIs)
- Select download folder via SAF tree picker
- Downloads now write to SAF file descriptors (`/proc/self/fd/*`) instead of raw filesystem paths
- Works around Android 10+ scoped storage permission errors
- **Modern Onboarding Experience**: Completely redesigned Setup and Tutorial screens
### Added
- Home feed disk caching via SharedPreferences for instant restore on app startup
- SAF display path resolver in native Android layer (converts tree URIs to readable paths)
- New settings fields for storage mode + SAF tree URI
- SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF
- SAF library scan mode (DocumentFile traversal + metadata read)
- Incremental library scanning for filesystem and SAF paths (only scans new/modified files and detects removed files)
- Force Full Scan action in Library Settings to rescan all files on demand
- Downloaded files are now excluded from Local Library scan results to prevent duplicate entries
- Legacy library rows now support `file_mod_time` backfill before incremental scans (faster follow-up scans after upgrade)
- Library UI toggle to show SAF-repaired history items
- Scan cancelled banner + retry action for library scans
- Android DocumentFile dependency for SAF operations
- Post-processing API v2 (SAF-aware, ready to replace v1)
- Donate page in Settings with Ko-fi and Buy Me a Coffee links
- Per-App Language support on Android 13+ (locale_config.xml)
- Interactive tutorial with working search bar simulation and clickable download buttons
- Tutorial completion state is persisted after onboarding
- Visual feedback animations for page transitions, entrance effects, and feature lists
- New dedicated welcome step in setup wizard with improved branding
### Changed
- Download pipeline supports `output_path` + `output_ext` for Go backend
- Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled
- Post-processing hooks run for SAF content URIs (via temp file bridge)
- File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`)
- Local Library scan defaults to incremental mode; full rescan is available via Force Full Scan
- Local library database upgraded to schema v3 with `file_mod_time` tracking for incremental scan cache
- Platform channels expanded with incremental scan APIs (`scanLibraryFolderIncremental`) on Android and iOS
- Android platform channel adds `getSafFileModTimes` for SAF legacy cache backfill
- Android build tooling upgraded to Gradle 9.3.1 (wrapper)
- Android build path validated with Java 25 (Gradle/Kotlin/assemble debug)
- SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`)
- `MainActivity` host migrated to `FlutterFragmentActivity` for SAF picker compatibility
- Legacy `startActivityForResult` / `onActivityResult` SAF picker path removed
- Setup screen UI polish: smaller logo, thin outline borders on text fields
- Removed support section from About page (moved to Donate page)
- Qobuz squid.wtf region fallback for blocked regions
- Setup screen converted to PageView flow with animated progress bar and modern card layouts
- Tutorial screen aligned with Setup Screen design, updated typography and softened UI shapes
- Larger, more accessible navigation buttons for onboarding flow
- Reduced visual noise by removing unnecessary glow effects
### Fixed
- Android 10+ `permission denied` when writing to `/storage/emulated/0` (now handled via SAF)
- SAF history repair: auto-resolve missing content URIs using tree + filename
- SAF download fallback: retry in app-private storage when SAF write fails
- Tidal DASH manifest writing when output path is a file descriptor (no invalid `.m4a` path)
- External LRC output in SAF mode
- Restored old-device renderer fallback while using `FlutterFragmentActivity` by injecting shell args from a custom `FlutterFragment` (`--enable-impeller=false` on problematic devices)
- Preserved Flutter fragment creation behavior (cached engine, engine group, new engine) while adding Impeller fallback support
- SAF tree picker result now consistently returns `tree_uri` payload with persisted URI permission handling
- SAF share file now copies to temp before sharing (fixes share from SAF content URI)
- Home feed not updating after installing extension with homeFeed capability (no longer requires app restart)
- Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time)
- Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker)
- One-time SAF migration prompt for users updating from pre-SAF versions
- Fixed `fileModTime` propagation across Go/Android/Dart so incremental scan cache is stored and reused correctly
- Fixed SAF incremental scan key mismatch (`lastModified` vs `fileModTime`) and normalized result fields (`skippedCount`, `totalFiles`)
- Fixed incremental scan progress when all files are skipped (`scanned_files` now reaches total files)
- Removed duplicate `"removeExtension"` branch in Android method channel handler (eliminates Kotlin duplicate-branch warning)
---
## [3.4.2] - 2026-02-04
### Improved
- **Mobile Network Reliability**: All providers (Qobuz, Tidal, Amazon, Deezer) now have retry logic with exponential backoff
- Increased API timeouts: 15s → 25s (Deezer, Qobuz, Tidal), 30s (Amazon)
- Up to 3 retry attempts per API call (500ms → 1s → 2s backoff)
- Retryable: timeout, connection reset/refused, EOF, HTTP 5xx, HTTP 429
- **SongLink ID Extraction**: Extract QobuzID/TidalID directly from SongLink URLs
- New fields in `TrackAvailability`: `QobuzID`, `TidalID`
- Qobuz/Tidal now use direct Track ID from SongLink instead of re-parsing URLs
- **Qobuz Download Flow**: New Strategy 3 - get QobuzID from SongLink before ISRC search
- Cache hit now uses `GetTrackByID()` directly instead of searching again
- Pre-warm cache tries SongLink first before direct ISRC search
- **Tidal Download Flow**: Use `availability.TidalID` directly from SongLink struct
---
## [3.4.1] - 2026-02-04
### Fixed
- Metadata Priority order now persists after app restart
- Download Provider Priority order now persists after app restart
---
## [3.4.0] - 2026-02-03
### Highlights
@@ -78,7 +374,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- **Lossy Bitrate Options**: MP3 (320/256/192/128kbps), Opus (128/96/64kbps)
- **Search Filters**: Filter results by type (Tracks, Artists, Albums, Playlists)
- **Album/Playlist Search**: Deezer search now includes albums and playlists
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re*Index.(ot_inc))
- **New Languages**: Turkish (Kaan, BedirhanGltkn), Japanese (Re\*Index.(ot_inc))
- **Optional All Files Access**: Android 13+ no longer requires full storage access; enable in Settings if needed
- **Improved VPN Compatibility**: Better HTTP/2 support for users behind VPN or restricted networks
@@ -198,7 +494,7 @@ Same as 3.3.1 but fixes crash issues caused by FFmpeg.
- Added `genre`, `label`, `copyright` fields to `DownloadHistoryItem` model
- Metadata is stored in download history and persists across app restarts
- New localization strings: `trackGenre`, `trackLabel`, `trackCopyright`
- `**utils.randomUserAgent()` for Extensions**: New utility function for extensions to get random browser User-Agent strings
- `**utils.randomUserAgent()` for Extensions\*\*: New utility function for extensions to get random browser User-Agent strings
- Returns modern Chrome User-Agent format: `Chrome/{120-145}.0.{6000-7499}.{100-299}` with `Windows NT 10.0`
- Useful for extensions that need to rotate User-Agents to avoid detection
@@ -495,4 +791,4 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
---
*For older versions, see [GitHub Releases*](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
_For older versions, see [GitHub Releases_](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
+13 -19
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/516142f029a4f3642a899832a6f600acf07040170a98c106cd03222cf584d9a3)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/dec9c96672ab80e6bf6b7a66786e612f5404446c341eb0311b4cc78fe10c96a1)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)
<div align="center">
@@ -24,15 +24,6 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no acc
<img src="assets/images/4.jpg?v=2" width="200" />
</p>
## Search Source
SpotiFLAC supports multiple search sources for finding music metadata:
| Source | Setup |
|--------|-------|
| **Deezer** (Default) | No setup required |
| **Extensions** | Install additional search providers from the Store |
## Extensions
Extensions allow the community to add new music sources and features without waiting for app updates. When a streaming service API changes or a new source becomes available, extensions can be updated independently.
@@ -54,15 +45,8 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window
## Telegram
<p align="center">
<a href="https://t.me/spotiflac">
<img src="https://img.shields.io/badge/Telegram-Channel-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel">
</a>
<a href="https://t.me/spotiflac_chat">
<img src="https://img.shields.io/badge/Telegram-Community-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Community">
</a>
</p>
[![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac)
[![Telegram Community](https://img.shields.io/badge/COMMUNITY-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac_chat)
## FAQ
@@ -108,6 +92,16 @@ You are solely responsible for:
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
## API Credits
- **Tidal**: [hifi-api](https://github.com/binimum/hifi-api), [music.binimum.org](https://music.binimum.org), [qqdl.site](https://qqdl.site), [squid.wtf](https://squid.wtf), [spotisaver.net](https://spotisaver.net)
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
- **Lyrics**: [LRCLib](https://lrclib.net)
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
+5 -3
View File
@@ -96,11 +96,13 @@ repositories {
}
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
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation("androidx.documentfile:documentfile:1.1.0")
implementation("androidx.activity:activity-ktx:1.12.3")
}
+2 -1
View File
@@ -21,7 +21,8 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="false"
android:enableOnBackInvokedCallback="true">
android:enableOnBackInvokedCallback="true"
android:localeConfig="@xml/locale_config">
<activity
android:name=".MainActivity"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="ru" />
<locale android:name="es-ES" />
<locale android:name="id" />
<locale android:name="pt-PT" />
<locale android:name="ja" />
<locale android:name="tr" />
<locale android:name="de" />
<locale android:name="fr" />
<locale android:name="hi" />
<locale android:name="ko" />
<locale android:name="nl" />
<locale android:name="zh" />
</locale-config>
+1 -1
View File
@@ -22,7 +22,7 @@ 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 {
+1 -1
View File
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+2 -2
View File
@@ -19,8 +19,8 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.0" apply false
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
}
include(":app")
+171 -82
View File
@@ -17,6 +17,13 @@ import (
"time"
)
// Amazon API timeout and retry configuration for mobile networks
const (
amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks
amazonMaxRetries = 2 // Number of retry attempts
amazonRetryDelay = 500 * time.Millisecond
)
type AmazonDownloader struct {
client *http.Client
}
@@ -36,15 +43,6 @@ type AfkarXYZResponse struct {
} `json:"data"`
}
func amazonIsASCIIString(s string) bool {
for _, r := range s {
if r > 127 {
return false
}
}
return true
}
func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
@@ -54,12 +52,50 @@ func NewAmazonDownloader() *AmazonDownloader {
return globalAmazonDownloader
}
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) {
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL)
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
var lastErr error
for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
if attempt > 0 {
delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay)
time.Sleep(delay)
}
req, err := http.NewRequest("GET", apiURL, nil)
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL)
if err == nil {
return downloadURL, fileName, nil
}
lastErr = err
errStr := err.Error()
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
if !isRetryable {
return "", "", err
}
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
}
// doAfkarXYZRequest performs a single request to AfkarXYZ API
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) {
ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err)
}
@@ -98,12 +134,22 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin
reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "")
GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024))
return apiResp.Data.DirectLink, fileName, nil
}
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) {
GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL)
if err != nil {
return "", "", err
}
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
return downloadURL, fileName, nil
}
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
@@ -142,7 +188,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
@@ -161,23 +207,23 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
closeErr := out.Close()
if err != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
@@ -197,44 +243,63 @@ type AmazonDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
downloader := NewAmazonDownloader()
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
amazonURL := ""
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" {
amazonURL = cached.AmazonURL
GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC)
}
}
songlink := NewSongLinkClient()
var availability *TrackAvailability
var err error
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
if amazonURL == "" {
if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found {
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
} else if req.SpotifyID != "" {
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
} else {
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
}
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if !availability.Amazon || availability.AmazonURL == "" {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
amazonURL = availability.AmazonURL
if req.ISRC != "" {
GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL)
}
}
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err)
}
if !availability.Amazon || availability.AmazonURL == "" {
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
}
if req.OutputDir != "." {
if !isSafOutput && req.OutputDir != "." {
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
}
}
// Download using AfkarXYZ API
downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL)
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL)
if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
}
@@ -249,11 +314,18 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
// START PARALLEL: Fetch cover and lyrics while downloading audio
@@ -273,7 +345,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return AmazonDownloadResult{}, ErrDownloadCancelled
}
@@ -346,59 +418,70 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
}
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
if isSafOutput {
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err)
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
GoLog("[Amazon] Lyrics embedded successfully\n")
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
GoLog("[Amazon] Lyrics embedded successfully\n")
}
}
} else if req.EmbedLyrics {
GoLog("[Amazon] No lyrics available from parallel fetch\n")
}
} else if req.EmbedLyrics {
GoLog("[Amazon] No lyrics available from parallel fetch\n")
}
GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality, err := GetAudioQuality(outputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
quality := AudioQuality{}
if isSafOutput {
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
} else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
quality, err = GetAudioQuality(outputPath)
if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
} else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
}
finalMeta, metaReadErr := ReadMetadata(outputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
req.ReleaseDate = finalMeta.Date
finalMeta, metaReadErr := ReadMetadata(outputPath)
if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
actualTrackNum = finalMeta.TrackNumber
actualDiscNum = finalMeta.DiscNumber
if finalMeta.Date != "" {
req.ReleaseDate = finalMeta.Date
}
}
}
// Add to ISRC index for fast duplicate checking
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
bitDepth := 0
sampleRate := 0
@@ -407,6 +490,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
sampleRate = quality.SampleRate
}
lyricsLRC := ""
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return AmazonDownloadResult{
FilePath: outputPath,
BitDepth: bitDepth,
@@ -418,5 +506,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum,
ISRC: req.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
+76 -14
View File
@@ -23,6 +23,10 @@ type AudioMetadata struct {
TrackNumber int
DiscNumber int
ISRC string
Label string
Copyright string
Composer string
Comment string
}
// MP3Quality represents MP3 specific quality info
@@ -171,6 +175,12 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
metadata.TrackNumber = parseTrackNumber(value)
case "TPA":
metadata.DiscNumber = parseTrackNumber(value)
case "TCM":
metadata.Composer = value
case "TPB":
metadata.Label = value
case "TCR":
metadata.Copyright = value
}
pos += 6 + frameSize
@@ -277,6 +287,16 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
metadata.DiscNumber = parseTrackNumber(value)
case "TSRC":
metadata.ISRC = value
case "TCOM":
metadata.Composer = value
case "TPUB":
metadata.Label = value
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractCommentFrame(frameData); v != "" {
metadata.Comment = v
}
}
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 {
if len(data) < 2 {
return ""
@@ -430,11 +490,12 @@ func extendedHeaderSize(data []byte, version byte) int {
return 0
}
var size int
if version == 3 {
switch version {
case 3:
size = int(binary.BigEndian.Uint32(data[:4]))
} else if version == 4 {
case 4:
size = syncsafeToInt(data[:4])
} else {
default:
return 0
}
if size <= 0 {
@@ -624,14 +685,6 @@ func readOggPageWithHeader(file *os.File) (*oggPage, error) {
}, nil
}
func readOggPage(file *os.File) ([]byte, error) {
page, err := readOggPageWithHeader(file)
if err != nil {
return nil, err
}
return page.data, nil
}
func collectOggPackets(file *os.File, maxPackets, maxPages int) ([][]byte, error) {
const maxPacketSize = 10 * 1024 * 1024
var packets [][]byte
@@ -786,6 +839,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.DiscNumber = parseTrackNumber(value)
case "ISRC":
metadata.ISRC = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
}
}
}
@@ -930,11 +991,12 @@ func extractMP3CoverArt(filePath string) ([]byte, string, error) {
}
var frameSize int
if majorVersion == 2 {
switch majorVersion {
case 2:
frameSize = int(tagData[pos+3])<<16 | int(tagData[pos+4])<<8 | int(tagData[pos+5])
} else if majorVersion == 4 {
case 4:
frameSize = int(tagData[pos+4])<<21 | int(tagData[pos+5])<<14 | int(tagData[pos+6])<<7 | int(tagData[pos+7])
} else {
default:
frameSize = int(tagData[pos+4])<<24 | int(tagData[pos+5])<<16 | int(tagData[pos+6])<<8 | int(tagData[pos+7])
}
+42 -1
View File
@@ -23,6 +23,11 @@ const (
deezerCacheTTL = 10 * time.Minute
deezerMaxParallelISRC = 10
// Deezer API timeout and retry configuration for mobile networks
deezerAPITimeoutMobile = 25 * time.Second
deezerMaxRetries = 2
deezerRetryDelay = 500 * time.Millisecond
)
type DeezerClient struct {
@@ -42,7 +47,7 @@ var (
func GetDeezerClient() *DeezerClient {
deezerClientOnce.Do(func() {
deezerClient = &DeezerClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
httpClient: NewMetadataHTTPClient(deezerAPITimeoutMobile),
searchCache: make(map[string]*cacheEntry),
albumCache: make(map[string]*cacheEntry),
artistCache: make(map[string]*cacheEntry),
@@ -992,6 +997,42 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin
}
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
var lastErr error
for attempt := 0; attempt <= deezerMaxRetries; attempt++ {
if attempt > 0 {
delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff
GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay)
time.Sleep(delay)
}
err := c.doGetJSON(ctx, endpoint, dst)
if err == nil {
return nil
}
lastErr = err
errStr := err.Error()
// Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") ||
strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429")
if !isRetryable {
return err
}
GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err)
}
return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr)
}
func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
+680 -26
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"
@@ -128,6 +129,9 @@ type DownloadRequest struct {
AlbumArtist string `json:"album_artist"`
CoverURL string `json:"cover_url"`
OutputDir string `json:"output_dir"`
OutputPath string `json:"output_path,omitempty"`
OutputFD int `json:"output_fd,omitempty"`
OutputExt string `json:"output_ext,omitempty"`
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"`
EmbedLyrics bool `json:"embed_lyrics"`
@@ -199,8 +203,10 @@ func DownloadTrack(requestJSON string) (string, error) {
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.OutputDir != "" {
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
@@ -240,6 +246,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
LyricsLRC: qobuzResult.LyricsLRC,
}
}
err = qobuzErr
@@ -257,9 +264,28 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
}
}
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:
return errorResponse("Unknown service: " + req.Service)
}
@@ -336,8 +362,10 @@ func DownloadWithFallback(requestJSON string) (string, error) {
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.OutputDir != "" {
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
@@ -402,6 +430,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC,
LyricsLRC: qobuzResult.LyricsLRC,
}
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
@@ -421,6 +450,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
}
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
@@ -527,34 +557,106 @@ func CleanupConnections() {
}
func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath)
if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
quality, qualityErr := GetAudioQuality(filePath)
duration := 0
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
isMp3 := strings.HasSuffix(lower, ".mp3")
isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
result := map[string]interface{}{
"title": metadata.Title,
"artist": metadata.Artist,
"album": metadata.Album,
"album_artist": metadata.AlbumArtist,
"date": metadata.Date,
"track_number": metadata.TrackNumber,
"disc_number": metadata.DiscNumber,
"isrc": metadata.ISRC,
"lyrics": metadata.Lyrics,
"duration": duration,
"title": "",
"artist": "",
"album": "",
"album_artist": "",
"date": "",
"track_number": 0,
"disc_number": 0,
"isrc": "",
"lyrics": "",
"genre": "",
"label": "",
"copyright": "",
"composer": "",
"comment": "",
"duration": 0,
}
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if isFlac {
metadata, err := ReadMetadata(filePath)
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)
@@ -565,10 +667,78 @@ func ReadFileMetadata(filePath string) (string, error) {
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 {
return setDownloadDir(path)
}
// AllowDownloadDir adds a directory to the extension file sandbox allowlist.
func AllowDownloadDir(path string) {
if strings.TrimSpace(path) == "" {
return
}
AddAllowedDownloadDir(path)
}
func CheckDuplicate(outputDir, isrc string) (string, error) {
existingFile, exists := CheckISRCExists(outputDir, isrc)
@@ -1055,6 +1225,443 @@ func errorResponse(msg string) (string, error) {
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 ====================
func InitExtensionSystem(extensionsDir, dataDir string) error {
@@ -1260,6 +1867,17 @@ func DownloadWithExtensionsJSON(requestJSON string) (string, error) {
return "", fmt.Errorf("invalid request: %w", err)
}
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)
}
result, err := DownloadWithExtensionFallback(req)
if err != nil {
return "", err
@@ -1958,6 +2576,35 @@ func RunPostProcessingJSON(filePath, metadataJSON string) (string, error) {
return string(jsonBytes), nil
}
func RunPostProcessingV2JSON(inputJSON, metadataJSON string) (string, error) {
var metadata map[string]interface{}
if metadataJSON != "" {
if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil {
metadata = make(map[string]interface{})
}
}
var input PostProcessInput
if inputJSON != "" {
if err := json.Unmarshal([]byte(inputJSON), &input); err != nil {
input = PostProcessInput{}
}
}
manager := GetExtensionManager()
result, err := manager.RunPostProcessingV2(input, metadata)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetPostProcessingProvidersJSON() (string, error) {
manager := GetExtensionManager()
providers := manager.GetPostProcessingProviders()
@@ -2136,6 +2783,13 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) {
return ScanLibraryFolder(folderPath)
}
// ScanLibraryFolderIncrementalJSON performs an incremental library scan
// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis)
// Returns IncrementalScanResult as JSON
func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) {
return ScanLibraryFolderIncremental(folderPath, existingFilesJSON)
}
func GetLibraryScanProgressJSON() string {
return GetLibraryScanProgress()
}
+146 -1
View File
@@ -1123,6 +1123,10 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
}
func buildOutputPath(req DownloadRequest) string {
if strings.TrimSpace(req.OutputPath) != "" {
return strings.TrimSpace(req.OutputPath)
}
metadata := map[string]interface{}{
"title": req.TrackName,
"artist": req.ArtistName,
@@ -1138,7 +1142,14 @@ func buildOutputPath(req DownloadRequest) string {
filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName))
}
return fmt.Sprintf("%s/%s.flac", req.OutputDir, filename)
ext := strings.TrimSpace(req.OutputExt)
if ext == "" {
ext = ".flac"
} else if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext)
}
func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) {
@@ -1340,11 +1351,21 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{}
type PostProcessResult struct {
Success bool `json:"success"`
NewFilePath string `json:"new_file_path,omitempty"`
NewFileURI string `json:"new_file_uri,omitempty"`
Error string `json:"error,omitempty"`
BitDepth int `json:"bit_depth,omitempty"`
SampleRate int `json:"sample_rate,omitempty"`
}
type PostProcessInput struct {
Path string `json:"path,omitempty"`
URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"`
IsSAF bool `json:"is_saf,omitempty"`
}
const PostProcessTimeout = 2 * time.Minute
func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
@@ -1409,6 +1430,75 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str
return &postResult, nil
}
func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadata map[string]interface{}, hookID string) (*PostProcessResult, error) {
if !p.extension.Manifest.HasPostProcessing() {
return nil, fmt.Errorf("extension '%s' does not support post-processing", p.extension.ID)
}
if !p.extension.Enabled {
return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID)
}
p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock()
metadataJSON, _ := json.Marshal(metadata)
inputJSON, _ := json.Marshal(input)
filePath := input.Path
script := fmt.Sprintf(`
(function() {
if (typeof extension !== 'undefined') {
if (typeof extension.postProcessV2 === 'function') {
return extension.postProcessV2(%s, %s, %q);
}
if (typeof extension.postProcess === 'function') {
return extension.postProcess(%q, %s, %q);
}
}
return null;
})()
`, string(inputJSON), string(metadataJSON), hookID, filePath, string(metadataJSON), hookID)
result, err := RunWithTimeoutAndRecover(p.vm, script, PostProcessTimeout)
if err != nil {
errMsg := err.Error()
if IsTimeoutError(err) {
errMsg = "postProcess timeout: extension took too long to complete"
}
return &PostProcessResult{
Success: false,
Error: errMsg,
}, nil
}
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
return &PostProcessResult{
Success: false,
Error: "postProcess returned null",
}, nil
}
exported := result.Export()
jsonBytes, err := json.Marshal(exported)
if err != nil {
return &PostProcessResult{
Success: false,
Error: fmt.Sprintf("failed to marshal result: %v", err),
}, nil
}
var postResult PostProcessResult
if err := json.Unmarshal(jsonBytes, &postResult); err != nil {
return &PostProcessResult{
Success: false,
Error: fmt.Sprintf("failed to parse result: %v", err),
}, nil
}
return &postResult, nil
}
func (m *ExtensionManager) GetSearchProviders() []*ExtensionProviderWrapper {
m.mu.RLock()
defer m.mu.RUnlock()
@@ -1531,3 +1621,58 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil
}
// RunPostProcessingV2 runs all enabled post-processing hooks on a file input.
func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) {
providers := m.GetPostProcessingProviders()
if len(providers) == 0 {
return &PostProcessResult{Success: true, NewFilePath: input.Path, NewFileURI: input.URI}, nil
}
currentInput := input
for _, provider := range providers {
hooks := provider.extension.Manifest.GetPostProcessingHooks()
for _, hook := range hooks {
if !hook.DefaultEnabled {
continue
}
ext := strings.ToLower(filepath.Ext(currentInput.Path))
if ext == "" && currentInput.Name != "" {
ext = strings.ToLower(filepath.Ext(currentInput.Name))
}
if len(hook.SupportedFormats) > 0 && ext != "" {
supported := false
for _, format := range hook.SupportedFormats {
if "."+format == ext || format == ext[1:] {
supported = true
break
}
}
if !supported {
continue
}
}
GoLog("[PostProcessV2] Running hook %s from %s on %s\n", hook.ID, provider.extension.ID, currentInput.Path)
result, err := provider.PostProcessV2(currentInput, metadata, hook.ID)
if err != nil {
GoLog("[PostProcessV2] Hook %s failed: %v\n", hook.ID, err)
continue
}
if result.Success && result.NewFilePath != "" {
currentInput.Path = result.NewFilePath
if currentInput.Name == "" {
currentInput.Name = filepath.Base(result.NewFilePath)
}
}
if result.Success && result.NewFileURI != "" {
currentInput.URI = result.NewFileURI
}
}
}
return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil
}
+5 -5
View File
@@ -2,15 +2,15 @@ module github.com/zarz/spotiflac_android/go_backend
go 1.25.0
toolchain go1.25.6
toolchain go1.25.7
require (
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
github.com/go-flac/flacpicture v0.3.0
github.com/go-flac/flacvorbis v0.2.0
github.com/go-flac/go-flac v1.0.0
github.com/go-flac/flacpicture/v2 v2.0.2
github.com/go-flac/flacvorbis/v2 v2.0.2
github.com/go-flac/go-flac/v2 v2.0.4
github.com/refraction-networking/utls v1.8.2
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3
golang.org/x/net v0.49.0
)
+16 -8
View File
@@ -2,16 +2,18 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I=
github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI=
github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs=
github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI=
github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY=
github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8=
github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE=
github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo=
github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ=
github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g=
github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0=
github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
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/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4 h1:C3JuLOLhdaE75vk5m7u18NvZciRk+lnO34xcXl3NPTU=
golang.org/x/mobile v0.0.0-20260120165949-40bd9ace6ce4/go.mod h1:yHJY0EGzMJ0i5ONrrhdpDSSnoyres5LO7D2hSIbJJ5I=
golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4=
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/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
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=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+31
View File
@@ -55,6 +55,27 @@ var sharedTransport = &http.Transport{
DisableCompression: true,
}
// metadataTransport is a separate transport for metadata API calls (Deezer, Spotify, SongLink).
// Isolated from download traffic so that download failures cannot poison
// the connection pool used by metadata enrichment.
var metadataTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 30,
MaxIdleConnsPerHost: 5,
MaxConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false,
ForceAttemptHTTP2: true,
WriteBufferSize: 32 * 1024,
ReadBufferSize: 32 * 1024,
DisableCompression: true,
}
var sharedClient = &http.Client{
Transport: sharedTransport,
Timeout: DefaultTimeout,
@@ -72,6 +93,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
}
}
// NewMetadataHTTPClient creates an HTTP client using the isolated metadata transport.
// Use this for API calls that should not be affected by download traffic.
func NewMetadataHTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: metadataTransport,
Timeout: timeout,
}
}
func GetSharedClient() *http.Client {
return sharedClient
}
@@ -82,6 +112,7 @@ func GetDownloadClient() *http.Client {
func CloseIdleConnections() {
sharedTransport.CloseIdleConnections()
metadataTransport.CloseIdleConnections()
}
// Also checks for ISP blocking on errors
+194
View File
@@ -20,6 +20,7 @@ type LibraryScanResult struct {
FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"`
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
@@ -40,6 +41,14 @@ type LibraryScanProgress struct {
IsComplete bool `json:"is_complete"`
}
// IncrementalScanResult contains results of an incremental library scan
type IncrementalScanResult struct {
Scanned []LibraryScanResult `json:"scanned"` // New or updated files
DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist
SkippedCount int `json:"skippedCount"` // Files that were unchanged
TotalFiles int `json:"totalFiles"` // Total files in folder
}
var (
libraryScanProgress LibraryScanProgress
libraryScanProgressMu sync.RWMutex
@@ -179,6 +188,11 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
Format: strings.TrimPrefix(ext, "."),
}
// Get file modification time
if info, err := os.Stat(filePath); err == nil {
result.FileModTime = info.ModTime().UnixMilli()
}
libraryCoverCacheMu.RLock()
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
@@ -413,3 +427,183 @@ func ReadAudioMetadata(filePath string) (string, error) {
return string(jsonBytes), nil
}
// ScanLibraryFolderIncremental performs an incremental scan of the library folder
// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis)
// Only files that are new or have changed modification time will be scanned
func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) {
if folderPath == "" {
return "{}", fmt.Errorf("folder path is empty")
}
info, err := os.Stat(folderPath)
if err != nil {
return "{}", fmt.Errorf("folder not found: %w", err)
}
if !info.IsDir() {
return "{}", fmt.Errorf("path is not a folder: %s", folderPath)
}
// Parse existing files map
existingFiles := make(map[string]int64)
if existingFilesJSON != "" && existingFilesJSON != "{}" {
if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil {
GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err)
}
}
GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles))
// Reset progress
libraryScanProgressMu.Lock()
libraryScanProgress = LibraryScanProgress{}
libraryScanProgressMu.Unlock()
// Setup cancellation
libraryScanCancelMu.Lock()
if libraryScanCancel != nil {
close(libraryScanCancel)
}
libraryScanCancel = make(chan struct{})
cancelCh := libraryScanCancel
libraryScanCancelMu.Unlock()
// Collect all audio files with their mod times
type fileInfo struct {
path string
modTime int64
}
var currentFiles []fileInfo
currentPathSet := make(map[string]bool)
err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
select {
case <-cancelCh:
return fmt.Errorf("scan cancelled")
default:
}
if !info.IsDir() {
ext := strings.ToLower(filepath.Ext(path))
if supportedAudioFormats[ext] {
currentFiles = append(currentFiles, fileInfo{
path: path,
modTime: info.ModTime().UnixMilli(),
})
currentPathSet[path] = true
}
}
return nil
})
if err != nil {
return "{}", err
}
totalFiles := len(currentFiles)
libraryScanProgressMu.Lock()
libraryScanProgress.TotalFiles = totalFiles
libraryScanProgressMu.Unlock()
// Find files to scan (new or modified)
var filesToScan []fileInfo
skippedCount := 0
for _, f := range currentFiles {
existingModTime, exists := existingFiles[f.path]
if !exists {
// New file
filesToScan = append(filesToScan, f)
} else if f.modTime != existingModTime {
// Modified file
filesToScan = append(filesToScan, f)
} else {
// Unchanged file - skip
skippedCount++
}
}
// Find deleted files
var deletedPaths []string
for existingPath := range existingFiles {
if !currentPathSet[existingPath] {
deletedPaths = append(deletedPaths, existingPath)
}
}
GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n",
len(filesToScan), skippedCount, len(deletedPaths))
if len(filesToScan) == 0 {
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.IsComplete = true
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
result := IncrementalScanResult{
Scanned: []LibraryScanResult{},
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// Scan the files that need scanning
results := make([]LibraryScanResult, 0, len(filesToScan))
scanTime := time.Now().UTC().Format(time.RFC3339)
errorCount := 0
for i, f := range filesToScan {
select {
case <-cancelCh:
return "{}", fmt.Errorf("scan cancelled")
default:
}
libraryScanProgressMu.Lock()
libraryScanProgress.ScannedFiles = skippedCount + i + 1
libraryScanProgress.CurrentFile = filepath.Base(f.path)
libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100
libraryScanProgressMu.Unlock()
result, err := scanAudioFile(f.path, scanTime)
if err != nil {
errorCount++
GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err)
continue
}
results = append(results, *result)
}
libraryScanProgressMu.Lock()
libraryScanProgress.ErrorCount = errorCount
libraryScanProgress.IsComplete = true
libraryScanProgress.ScannedFiles = totalFiles
libraryScanProgress.ProgressPct = 100
libraryScanProgressMu.Unlock()
GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n",
len(results), skippedCount, len(deletedPaths), errorCount)
scanResult := IncrementalScanResult{
Scanned: results,
DeletedPaths: deletedPaths,
SkippedCount: skippedCount,
TotalFiles: totalFiles,
}
jsonBytes, err := json.Marshal(scanResult)
if err != nil {
return "{}", fmt.Errorf("failed to marshal results: %w", err)
}
return string(jsonBytes), nil
}
+17 -2
View File
@@ -22,6 +22,11 @@ type LogBuffer struct {
loggingEnabled bool
}
const (
defaultLogBufferSize = 500
maxLogMessageLength = 500
)
var (
globalLogBuffer *LogBuffer
logBufferOnce sync.Once
@@ -30,14 +35,22 @@ var (
func GetLogBuffer() *LogBuffer {
logBufferOnce.Do(func() {
globalLogBuffer = &LogBuffer{
entries: make([]LogEntry, 0, 1000),
maxSize: 1000,
entries: make([]LogEntry, 0, defaultLogBufferSize),
maxSize: defaultLogBufferSize,
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
}
})
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) {
lb.mu.Lock()
defer lb.mu.Unlock()
@@ -58,6 +71,8 @@ func (lb *LogBuffer) Add(level, tag, message string) {
return
}
message = truncateLogMessage(message)
entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"),
Level: level,
+27 -3
View File
@@ -9,9 +9,9 @@ import (
"strconv"
"strings"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
"github.com/go-flac/flacpicture/v2"
"github.com/go-flac/flacvorbis/v2"
"github.com/go-flac/go-flac/v2"
)
type Metadata struct {
@@ -29,6 +29,8 @@ type Metadata struct {
Genre string
Label string
Copyright string
Composer string
Comment string
}
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
@@ -98,6 +100,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -206,6 +216,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "COPYRIGHT", metadata.Copyright)
}
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal()
if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock
@@ -292,6 +310,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
metadata.Date = getComment(cmt, "YEAR")
}
metadata.Genre = getComment(cmt, "GENRE")
metadata.Label = getComment(cmt, "ORGANIZATION")
metadata.Copyright = getComment(cmt, "COPYRIGHT")
metadata.Composer = getComment(cmt, "COMPOSER")
metadata.Comment = getComment(cmt, "COMMENT")
break
}
}
+31
View File
@@ -0,0 +1,31 @@
package gobackend
import (
"fmt"
"os"
"strings"
)
func isFDOutput(outputFD int) bool {
return outputFD > 0
}
func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) {
if isFDOutput(outputFD) {
return os.NewFile(uintptr(outputFD), fmt.Sprintf("saf_fd_%d", outputFD)), nil
}
return os.Create(outputPath)
}
func cleanupOutputOnError(outputPath string, outputFD int) {
if isFDOutput(outputFD) {
return
}
path := strings.TrimSpace(outputPath)
if path == "" || strings.HasPrefix(path, "/proc/self/fd/") {
return
}
_ = os.Remove(path)
}
+58 -8
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"encoding/json"
"fmt"
"sync"
"time"
@@ -9,7 +10,7 @@ import (
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
AmazonTrackID string
AmazonURL string
ExpiresAt time.Time
}
@@ -106,7 +107,7 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
}
}
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -115,7 +116,7 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.AmazonTrackID = trackID
entry.AmazonURL = amazonURL
now := time.Now()
entry.ExpiresAt = now.Add(c.ttl)
@@ -156,17 +157,20 @@ func FetchCoverAndLyricsParallel(
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
var resultMu sync.Mutex
if coverURL != "" {
wg.Add(1)
go func() {
defer wg.Done()
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
resultMu.Lock()
if err != nil {
result.CoverErr = err
} else {
result.CoverData = data
}
resultMu.Unlock()
}()
}
@@ -177,6 +181,7 @@ func FetchCoverAndLyricsParallel(
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
resultMu.Lock()
if err != nil {
result.LyricsErr = err
} else if lyrics != nil && len(lyrics.Lines) > 0 {
@@ -185,6 +190,7 @@ func FetchCoverAndLyricsParallel(
} else {
result.LyricsErr = fmt.Errorf("no lyrics found")
}
resultMu.Unlock()
}()
}
@@ -211,6 +217,9 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
var wg sync.WaitGroup
for _, req := range requests {
if req.ISRC == "" {
continue
}
if cached := cache.Get(req.ISRC); cached != nil {
continue
}
@@ -225,7 +234,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
case "tidal":
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz":
preWarmQobuzCache(r.ISRC)
preWarmQobuzCache(r.ISRC, r.SpotifyID)
case "amazon":
preWarmAmazonCache(r.ISRC, r.SpotifyID)
}
@@ -243,10 +252,30 @@ func preWarmTidalCache(isrc, _, _ string) {
}
}
func preWarmQobuzCache(isrc string) {
// preWarmQobuzCache tries to get Qobuz Track ID in the following order:
// 1. From SongLink (fast, no Qobuz API call needed)
// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database)
func preWarmQobuzCache(isrc, spotifyID string) {
// First, try to get QobuzID from SongLink - this is faster and more reliable
if spotifyID != "" {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.QobuzID != "" {
// Parse QobuzID to int64
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc)
GetTrackIDCache().SetQobuz(isrc, trackID)
return
}
}
}
// Fallback: Direct ISRC search on Qobuz API
downloader := NewQobuzDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc)
GetTrackIDCache().SetQobuz(isrc, track.ID)
}
}
@@ -254,13 +283,34 @@ func preWarmQobuzCache(isrc string) {
func preWarmAmazonCache(isrc, spotifyID string) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
if err == nil && availability != nil && availability.AmazonURL != "" {
GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL)
}
}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
SpotifyID string `json:"spotify_id"`
Service string `json:"service"`
}
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
return fmt.Errorf("failed to parse tracks JSON: %w", err)
}
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
SpotifyID: t.SpotifyID,
Service: t.Service,
}
}
go PreWarmTrackCache(requests)
return nil
+264 -100
View File
@@ -380,6 +380,42 @@ func decodeXOR(data []byte) string {
return string(result)
}
func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
var raw map[string]any
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("invalid JSON: %v", err)
}
if errMsg, ok := raw["error"].(string); ok && strings.TrimSpace(errMsg) != "" {
return "", fmt.Errorf("%s", errMsg)
}
if success, ok := raw["success"].(bool); ok && !success {
if msg, ok := raw["message"].(string); ok && strings.TrimSpace(msg) != "" {
return "", fmt.Errorf("%s", msg)
}
return "", fmt.Errorf("api returned success=false")
}
if urlVal, ok := raw["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
if linkVal, ok := raw["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
return strings.TrimSpace(linkVal), nil
}
if data, ok := raw["data"].(map[string]any); ok {
if urlVal, ok := data["url"].(string); ok && strings.TrimSpace(urlVal) != "" {
return strings.TrimSpace(urlVal), nil
}
if linkVal, ok := data["link"].(string); ok && strings.TrimSpace(linkVal) != "" {
return strings.TrimSpace(linkVal), nil
}
}
return "", fmt.Errorf("no download URL in response")
}
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality)
region := "US"
@@ -725,75 +761,151 @@ type qobuzAPIResult struct {
duration time.Duration
}
// Qobuz API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
qobuzAPITimeoutMobile = 25 * time.Second
qobuzMaxRetries = 2 // Number of retries per API
qobuzRetryDelay = 500 * time.Millisecond
)
// getQobuzAPITimeout returns appropriate timeout based on platform
// For mobile (gomobile builds), we use longer timeouts
func getQobuzAPITimeout() time.Duration {
// Since this runs in gomobile context, we always use mobile timeout
// The Go backend is only used on mobile (Android/iOS)
return qobuzAPITimeoutMobile
}
// qobuzSquidCountries defines the region fallback order for squid.wtf API
var qobuzSquidCountries = []string{"US", "FR"}
// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic
// For squid.wtf APIs, it tries US region first, then falls back to FR
func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) {
isSquid := strings.Contains(api, "squid.wtf")
if isSquid {
for _, country := range qobuzSquidCountries {
GoLog("[Qobuz] Trying squid.wtf with country=%s\n", country)
result, err := fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, country)
if err == nil {
return result, nil
}
GoLog("[Qobuz] squid.wtf country=%s failed: %v\n", country, err)
}
return "", fmt.Errorf("squid.wtf failed for all regions (US, FR)")
}
return fetchQobuzURLSingleAttempt(api, trackID, quality, timeout, "")
}
// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination
func fetchQobuzURLSingleAttempt(api string, trackID int64, quality string, timeout time.Duration, country string) (string, error) {
var lastErr error
retryDelay := qobuzRetryDelay
for attempt := 0; attempt <= qobuzMaxRetries; attempt++ {
if attempt > 0 {
GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
}
client := NewHTTPClientWithTimeout(timeout)
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
if country != "" {
reqURL += "&country=" + country
}
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
lastErr = err
continue
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
// Check for retryable errors (timeout, connection reset)
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") {
continue // Retry
}
break // Non-retryable error
}
// Server errors are retryable
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
continue
}
// 429 rate limit - wait and retry
if resp.StatusCode == 429 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("rate limited")
retryDelay = 2 * time.Second // Wait longer for rate limit
continue
}
if resp.StatusCode != 200 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
if len(body) > 0 && body[0] == '<' {
return "", fmt.Errorf("received HTML instead of JSON")
}
urlVal, parseErr := extractQobuzDownloadURLFromBody(body)
if parseErr == nil {
return urlVal, nil
}
lastErr = parseErr
continue
}
if lastErr != nil {
return "", lastErr
}
return "", fmt.Errorf("all retries failed")
}
func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) {
if len(apis) == 0 {
return "", "", fmt.Errorf("no APIs available")
}
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis))
GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
resultChan := make(chan qobuzAPIResult, len(apis))
startTime := time.Now()
timeout := getQobuzAPITimeout()
for _, apiURL := range apis {
go func(api string) {
reqStart := time.Now()
client := NewHTTPClientWithTimeout(15 * time.Second)
reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout)
resultChan <- qobuzAPIResult{
apiURL: api,
downloadURL: downloadURL,
err: err,
duration: time.Since(reqStart),
}
resp, err := client.Do(req)
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
if len(body) > 0 && body[0] == '<' {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)}
return
}
var errorResp struct {
Error string `json:"error"`
}
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)}
return
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)}
return
}
if result.URL != "" {
resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)}
return
}
resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)}
}(apiURL)
}
@@ -860,7 +972,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err)
}
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
@@ -897,7 +1009,7 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
@@ -916,23 +1028,23 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
closeErr := out.Close()
if err != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
@@ -950,13 +1062,17 @@ type QobuzDownloadResult struct {
TrackNumber int
DiscNumber int
ISRC string
LyricsLRC string
}
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
downloader := NewQobuzDownloader()
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
expectedDurationSec := req.DurationMS / 1000
@@ -964,6 +1080,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
var track *QobuzTrack
var err error
// Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate)
if req.QobuzID != "" {
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
var trackID int64
@@ -978,17 +1095,43 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
}
// Strategy 2: Use cached Qobuz Track ID (fast, no search needed)
if track == nil && req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
track, err = downloader.SearchTrackByISRC(req.ISRC)
track, err = downloader.GetTrackByID(cached.QobuzTrackID)
if err != nil {
GoLog("[Qobuz] Cache hit but search failed: %v\n", err)
GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err)
track = nil
}
}
}
// Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID
if track == nil && req.SpotifyID != "" && req.QobuzID == "" {
GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID)
songLinkClient := NewSongLinkClient()
availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if slErr == nil && availability != nil && availability.QobuzID != "" {
var trackID int64
if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID)
track, err = downloader.GetTrackByID(trackID)
if err != nil {
GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err)
track = nil
} else if track != nil {
GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
// Cache for future use
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
}
}
}
}
// Strategy 4: ISRC search with duration verification
if track == nil && req.ISRC != "" {
GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC)
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
@@ -1005,7 +1148,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
}
// Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds)
if track == nil {
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
@@ -1035,11 +1180,18 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ".flac"
outputPath := filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
var outputPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
}
qobuzQuality := "27"
@@ -1077,7 +1229,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
)
}()
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return QobuzDownloadResult{}, ErrDownloadCancelled
}
@@ -1122,39 +1274,50 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
GoLog("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
if isSafOutput {
GoLog("[Qobuz] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
} else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Qobuz] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
}
}
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
}
lyricsLRC := ""
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Qobuz] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Qobuz] Warning: failed to save LRC file: %v\n", lrcErr)
} else {
GoLog("[Qobuz] LRC file saved: %s\n", lrcPath)
}
}
if lyricsMode == "embed" || lyricsMode == "both" {
GoLog("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
}
} else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
lyricsLRC = parallelResult.LyricsLRC
}
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath)
return QobuzDownloadResult{
FilePath: outputPath,
BitDepth: actualBitDepth,
@@ -1166,5 +1329,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: actualTrackNumber,
DiscNumber: req.DiscNumber,
ISRC: track.ISRC,
LyricsLRC: lyricsLRC,
}, nil
}
+47
View File
@@ -0,0 +1,47 @@
package gobackend
import "testing"
func TestExtractQobuzDownloadURLFromBody(t *testing.T) {
t.Run("reads nested data.url", func(t *testing.T) {
body := []byte(`{"success":true,"data":{"url":"https://example.test/audio.flac"}}`)
got, err := extractQobuzDownloadURLFromBody(body)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != "https://example.test/audio.flac" {
t.Fatalf("unexpected URL: %q", got)
}
})
t.Run("reads top-level url", func(t *testing.T) {
body := []byte(`{"url":"https://example.test/top.flac"}`)
got, err := extractQobuzDownloadURLFromBody(body)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got != "https://example.test/top.flac" {
t.Fatalf("unexpected URL: %q", got)
}
})
t.Run("returns API error", func(t *testing.T) {
body := []byte(`{"error":"track not found"}`)
_, err := extractQobuzDownloadURLFromBody(body)
if err == nil || err.Error() != "track not found" {
t.Fatalf("expected track-not-found error, got %v", err)
}
})
t.Run("returns message when success false", func(t *testing.T) {
body := []byte(`{"success":false,"message":"blocked"}`)
_, err := extractQobuzDownloadURLFromBody(body)
if err == nil || err.Error() != "blocked" {
t.Fatalf("expected blocked error, got %v", err)
}
})
}
+263 -46
View File
@@ -8,7 +8,6 @@ import (
"net/url"
"strings"
"sync"
"time"
)
type SongLinkClient struct {
@@ -16,16 +15,21 @@ type SongLinkClient struct {
}
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"`
YouTube bool `json:"youtube"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"`
YouTubeURL string `json:"youtube_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
YouTubeID string `json:"youtube_id,omitempty"`
}
var (
@@ -36,7 +40,7 @@ var (
func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout),
client: NewMetadataHTTPClient(SongLinkTimeout),
}
})
return globalSongLinkClient
@@ -98,6 +102,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
@@ -111,6 +116,28 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = 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
}
@@ -131,40 +158,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str
return urls, nil
}
func checkQobuzAvailability(isrc string) bool {
client := NewHTTPClientWithTimeout(10 * time.Second)
appID := "798273057"
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return false
}
resp, err := DoRequestWithUserAgent(client, req)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
var searchResp struct {
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return false
}
return searchResp.Tracks.Total > 0
}
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
func extractDeezerIDFromURL(deezerURL string) string {
parts := strings.Split(deezerURL, "/")
@@ -178,6 +171,148 @@ func extractDeezerIDFromURL(deezerURL string) string {
return ""
}
// extractQobuzIDFromURL extracts Qobuz track ID from URL
// URL formats:
// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight)
// - https://open.qobuz.com/track/12345678
// - https://www.qobuz.com/track/12345678
// - https://play.qobuz.com/track/12345678
func extractQobuzIDFromURL(qobuzURL string) string {
if qobuzURL == "" {
return ""
}
// Try to find /track/ID pattern first
if strings.Contains(qobuzURL, "/track/") {
parts := strings.Split(qobuzURL, "/track/")
if len(parts) > 1 {
idPart := parts[1]
// Remove query parameters
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
// Remove trailing slash or path
if idx := strings.Index(idPart, "/"); idx > 0 {
idPart = idPart[:idx]
}
idPart = strings.TrimSpace(idPart)
// Validate it's a number
if idPart != "" && isNumeric(idPart) {
return idPart
}
}
}
// Try to extract from album URL with track highlight
// Format: /album/albumname/trackid or ?trackId=12345678
if strings.Contains(qobuzURL, "trackId=") {
parts := strings.Split(qobuzURL, "trackId=")
if len(parts) > 1 {
idPart := parts[1]
if idx := strings.Index(idPart, "&"); idx > 0 {
idPart = idPart[:idx]
}
idPart = strings.TrimSpace(idPart)
if idPart != "" && isNumeric(idPart) {
return idPart
}
}
}
// Last resort: get last numeric segment from URL
parts := strings.Split(qobuzURL, "/")
for i := len(parts) - 1; i >= 0; i-- {
part := parts[i]
// Remove query parameters
if idx := strings.Index(part, "?"); idx > 0 {
part = part[:idx]
}
part = strings.TrimSpace(part)
if part != "" && isNumeric(part) {
return part
}
}
return ""
}
// extractTidalIDFromURL extracts Tidal track ID from URL
// URL formats:
// - https://tidal.com/browse/track/12345678
// - https://listen.tidal.com/track/12345678
func extractTidalIDFromURL(tidalURL string) string {
if tidalURL == "" {
return ""
}
if strings.Contains(tidalURL, "/track/") {
parts := strings.Split(tidalURL, "/track/")
if len(parts) > 1 {
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
if idx := strings.Index(idPart, "/"); idx > 0 {
idPart = idPart[:idx]
}
idPart = strings.TrimSpace(idPart)
if idPart != "" && isNumeric(idPart) {
return idPart
}
}
}
return ""
}
// extractYouTubeIDFromURL extracts YouTube video ID from URL
// URL formats:
// - https://www.youtube.com/watch?v=VIDEO_ID
// - https://youtu.be/VIDEO_ID
// - https://music.youtube.com/watch?v=VIDEO_ID
func extractYouTubeIDFromURL(youtubeURL string) string {
if youtubeURL == "" {
return ""
}
// Handle youtu.be short URLs
if strings.Contains(youtubeURL, "youtu.be/") {
parts := strings.Split(youtubeURL, "youtu.be/")
if len(parts) >= 2 {
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
if idx := strings.Index(idPart, "&"); idx > 0 {
idPart = idPart[:idx]
}
return strings.TrimSpace(idPart)
}
}
// Handle youtube.com URLs with ?v= parameter
parsed, err := url.Parse(youtubeURL)
if err != nil {
return ""
}
if v := parsed.Query().Get("v"); v != "" {
return v
}
// Handle /embed/ format
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0]
}
}
return ""
}
// isNumeric is defined in library_scan.go
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
@@ -191,6 +326,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
return availability.DeezerID, nil
}
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
// AlbumAvailability represents album availability on different platforms
type AlbumAvailability struct {
SpotifyID string `json:"spotify_id"`
@@ -353,6 +502,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
@@ -360,10 +510,29 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && 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
}
@@ -431,6 +600,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
@@ -438,12 +608,31 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability.AmazonURL = amazonLink.URL
}
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil
}
@@ -500,6 +689,20 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
return availability.AmazonURL, nil
}
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot()
@@ -552,6 +755,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
availability.TidalID = extractTidalIDFromURL(tidalLink.URL)
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
@@ -560,12 +764,25 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" {
availability.Qobuz = true
availability.QobuzURL = qobuzLink.URL
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
availability.Deezer = true
availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
}
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil
}
+1 -1
View File
@@ -114,7 +114,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
src := rand.NewSource(time.Now().UnixNano())
c := &SpotifyMetadataClient{
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
httpClient: NewMetadataHTTPClient(15 * time.Second),
clientID: clientID,
clientSecret: clientSecret,
rng: rand.New(src),
+289 -163
View File
@@ -582,12 +582,123 @@ type tidalAPIResult struct {
duration time.Duration
}
// Tidal API timeout configuration
// Mobile networks are more unstable, so we use longer timeouts
const (
tidalAPITimeoutMobile = 25 * time.Second
tidalMaxRetries = 2 // Number of retries per API
tidalRetryDelay = 500 * time.Millisecond
)
// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic
func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) {
var lastErr error
retryDelay := tidalRetryDelay
for attempt := 0; attempt <= tidalMaxRetries; attempt++ {
if attempt > 0 {
GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay)
time.Sleep(retryDelay)
retryDelay *= 2 // Exponential backoff
}
client := NewHTTPClientWithTimeout(timeout)
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
lastErr = err
continue
}
resp, err := client.Do(req)
if err != nil {
lastErr = err
// Check for retryable errors (timeout, connection reset)
errStr := strings.ToLower(err.Error())
if strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "reset") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "eof") {
continue // Retry
}
break // Non-retryable error
}
// Server errors are retryable
if resp.StatusCode >= 500 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
continue
}
// 429 rate limit - wait and retry
if resp.StatusCode == 429 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("rate limited")
retryDelay = 2 * time.Second // Wait longer for rate limit
continue
}
if resp.StatusCode != 200 {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = err
continue
}
// Try V2 response format (with manifest)
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
if v2Response.Data.AssetPresentation == "PREVIEW" {
return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL")
}
return TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
SampleRate: v2Response.Data.SampleRate,
}, nil
}
// Try V1 response format
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
return TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
SampleRate: 44100,
}, nil
}
}
}
return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response")
}
if lastErr != nil {
return TidalDownloadInfo{}, lastErr
}
return TidalDownloadInfo{}, fmt.Errorf("all retries failed")
}
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
if len(apis) == 0 {
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
}
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis))
resultChan := make(chan tidalAPIResult, len(apis))
startTime := time.Now()
@@ -595,69 +706,13 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
for _, apiURL := range apis {
go func(api string) {
reqStart := time.Now()
client := NewHTTPClientWithTimeout(15 * time.Second)
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile)
resultChan <- tidalAPIResult{
apiURL: api,
info: info,
err: err,
duration: time.Since(reqStart),
}
resp, err := client.Do(req)
if err != nil {
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
return
}
body, err := io.ReadAll(resp.Body)
if err != nil {
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
return
}
var v2Response TidalAPIResponseV2
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
if v2Response.Data.AssetPresentation == "PREVIEW" {
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
return
}
info := TidalDownloadInfo{
URL: "MANIFEST:" + v2Response.Data.Manifest,
BitDepth: v2Response.Data.BitDepth,
SampleRate: v2Response.Data.SampleRate,
}
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
return
}
var v1Responses []struct {
OriginalTrackURL string `json:"OriginalTrackUrl"`
}
if err := json.Unmarshal(body, &v1Responses); err == nil {
for _, item := range v1Responses {
if item.OriginalTrackURL != "" {
info := TidalDownloadInfo{
URL: item.OriginalTrackURL,
BitDepth: 16,
SampleRate: 44100,
}
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
return
}
}
}
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
}(apiURL)
}
@@ -784,6 +839,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
}
if segmentCount == 0 {
return "", "", nil, fmt.Errorf("no segments found in manifest")
}
for i := 1; i <= segmentCount; i++ {
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
mediaURLs = append(mediaURLs, mediaURL)
@@ -792,7 +851,7 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
return "", initURL, mediaURLs, nil
}
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if strings.HasPrefix(downloadURL, "MANIFEST:") {
@@ -805,7 +864,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, outputFD, itemID)
}
if itemID != "" {
@@ -842,7 +901,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return err
}
@@ -861,30 +920,30 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
closeErr := out.Close()
if err != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
return nil
}
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath string, outputFD int, itemID string) error {
fmt.Println("[Tidal] Parsing manifest...")
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
if err != nil {
@@ -929,7 +988,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
SetItemBytesTotal(itemID, expectedSize)
}
out, err := os.Create(outputPath)
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
@@ -945,19 +1004,19 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
closeErr := out.Close()
if err != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if closeErr != nil {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
os.Remove(outputPath)
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
@@ -965,17 +1024,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
}
// For DASH format, determine correct M4A path
// If outputPath already ends with .m4a, use it directly
// Otherwise, convert .flac to .m4a
// If outputPath already ends with .m4a, use it directly.
// If outputPath ends with .flac, convert .flac to .m4a.
// Otherwise (e.g., SAF /proc/self/fd/*), use outputPath as-is.
var m4aPath string
if strings.HasSuffix(outputPath, ".m4a") {
m4aPath = outputPath
} else {
} else if strings.HasSuffix(outputPath, ".flac") {
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
} else {
m4aPath = outputPath
}
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
out, err := os.Create(m4aPath)
out, err := openOutputForWrite(m4aPath, outputFD)
if err != nil {
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
return fmt.Errorf("failed to create M4A file: %w", err)
@@ -984,20 +1046,20 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
GoLog("[Tidal] Downloading init segment...\n")
if isDownloadCancelled(itemID) {
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
if err != nil {
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Init segment request failed: %v\n", err)
return fmt.Errorf("failed to create init segment request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
@@ -1007,7 +1069,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
if resp.StatusCode != 200 {
resp.Body.Close()
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Init segment HTTP error: %d\n", resp.StatusCode)
return fmt.Errorf("init segment download failed with status %d", resp.StatusCode)
}
@@ -1015,7 +1077,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
resp.Body.Close()
if err != nil {
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
@@ -1027,7 +1089,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
for i, mediaURL := range mediaURLs {
if isDownloadCancelled(itemID) {
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
return ErrDownloadCancelled
}
@@ -1043,14 +1105,14 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
if err != nil {
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err)
return fmt.Errorf("failed to create segment %d request: %w", i+1, err)
}
resp, err := client.Do(req)
if err != nil {
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
@@ -1060,7 +1122,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
if resp.StatusCode != 200 {
resp.Body.Close()
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Segment %d HTTP error: %d\n", i+1, resp.StatusCode)
return fmt.Errorf("segment %d download failed with status %d", i+1, resp.StatusCode)
}
@@ -1068,7 +1130,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
resp.Body.Close()
if err != nil {
out.Close()
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
@@ -1078,7 +1140,7 @@ func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64,
}
if err := out.Close(); err != nil {
os.Remove(m4aPath)
cleanupOutputOnError(m4aPath, outputFD)
GoLog("[Tidal] Failed to close M4A file: %v\n", err)
return fmt.Errorf("failed to close M4A file: %w", err)
}
@@ -1347,8 +1409,11 @@ func isLatinScript(s string) bool {
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
downloader := NewTidalDownloader()
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if !isSafOutput {
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
}
}
expectedDurationSec := req.DurationMS / 1000
@@ -1404,49 +1469,83 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if track == nil && req.SpotifyID != "" {
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
var tidalURL string
var slErr error
var trackID int64
var gotTidalID bool
if strings.HasPrefix(req.SpotifyID, "deezer:") {
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
songlink := NewSongLinkClient()
tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID)
availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID)
if slErr == nil && availability != nil && availability.TidalID != "" {
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
gotTidalID = true
}
}
// Fallback to URL parsing if TidalID not in struct
if !gotTidalID && availability != nil && availability.TidalURL != "" {
var idErr error
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
if idErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
gotTidalID = true
}
}
} else {
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
songlink := NewSongLinkClient()
availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
if slErr == nil && availability != nil && availability.TidalID != "" {
if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID)
gotTidalID = true
}
}
// Fallback to URL parsing if TidalID not in struct
if !gotTidalID && availability != nil && availability.TidalURL != "" {
var idErr error
trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL)
if idErr == nil && trackID > 0 {
GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID)
gotTidalID = true
}
}
}
if slErr == nil && tidalURL != "" {
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
if idErr == nil {
track, err = downloader.GetTrackInfoByID(trackID)
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
if gotTidalID && trackID > 0 {
track, err = downloader.GetTrackInfoByID(trackID)
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
if !artistsMatch(req.ArtistName, tidalArtist) {
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff > 3 {
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
if track != nil && expectedDurationSec > 0 {
durationDiff := track.Duration - expectedDurationSec
if durationDiff < 0 {
durationDiff = -durationDiff
}
if durationDiff > 3 {
GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n",
expectedDurationSec, track.Duration)
track = nil // Reject this match
}
}
// Cache for future use
if track != nil && req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
}
}
}
@@ -1513,31 +1612,52 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
"disc": req.DiscNumber,
})
var outputPath string
var m4aPath string
if quality == "HIGH" {
filename = sanitizeFilename(filename) + ".m4a"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = outputPath
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
outputExt := strings.TrimSpace(req.OutputExt)
if outputExt == "" {
if quality == "HIGH" {
outputExt = ".m4a"
} else {
outputExt = ".flac"
}
} else if !strings.HasPrefix(outputExt, ".") {
outputExt = "." + outputExt
}
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
if quality != "HIGH" {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
var outputPath string
var m4aPath string
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
m4aPath = outputPath
} else {
if outputExt == ".m4a" || quality == "HIGH" {
filename = sanitizeFilename(filename) + ".m4a"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = outputPath
} else {
filename = sanitizeFilename(filename) + ".flac"
outputPath = filepath.Join(req.OutputDir, filename)
m4aPath = strings.TrimSuffix(outputPath, ".flac") + ".m4a"
}
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
if quality != "HIGH" {
if fileInfo, statErr := os.Stat(m4aPath); statErr == nil && fileInfo.Size() > 0 {
return TidalDownloadResult{FilePath: "EXISTS:" + m4aPath}, nil
}
}
}
tmpPath := outputPath + ".m4a.tmp"
if _, err := os.Stat(tmpPath); err == nil {
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
os.Remove(tmpPath)
if !isSafOutput {
tmpPath := outputPath + ".m4a.tmp"
if _, err := os.Stat(tmpPath); err == nil {
GoLog("[Tidal] Cleaning up leftover temp file: %s\n", tmpPath)
os.Remove(tmpPath)
}
}
GoLog("[Tidal] Using quality: %s\n", quality)
@@ -1572,7 +1692,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return "Direct URL"
}())
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
if errors.Is(err, ErrDownloadCancelled) {
return TidalDownloadResult{}, ErrDownloadCancelled
}
@@ -1589,11 +1709,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
actualOutputPath := outputPath
if _, err := os.Stat(m4aPath); err == nil {
actualOutputPath = m4aPath
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
} else if _, err := os.Stat(outputPath); err != nil {
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
if !isSafOutput {
if _, err := os.Stat(m4aPath); err == nil {
actualOutputPath = m4aPath
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
} else if _, err := os.Stat(outputPath); err != nil {
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
}
}
releaseDate := req.ReleaseDate
@@ -1632,7 +1754,15 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
GoLog("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if strings.HasSuffix(actualOutputPath, ".flac") {
actualExt := outputExt
if strings.HasPrefix(downloadInfo.URL, "MANIFEST:") {
actualExt = ".m4a"
}
if actualExt == "" && !isSafOutput {
actualExt = strings.ToLower(filepath.Ext(actualOutputPath))
}
if (isSafOutput && actualExt == ".flac") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".flac")) {
if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
@@ -1643,7 +1773,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
GoLog("[Tidal] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
@@ -1663,7 +1793,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} else if req.EmbedLyrics {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else if strings.HasSuffix(actualOutputPath, ".m4a") {
} else if (isSafOutput && actualExt == ".m4a") || (!isSafOutput && strings.HasSuffix(actualOutputPath, ".m4a")) {
if quality == "HIGH" {
GoLog("[Tidal] HIGH quality M4A - skipping metadata embedding (file from server is already valid)\n")
@@ -1673,7 +1803,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
lyricsMode = "embed"
}
if lyricsMode == "external" || lyricsMode == "both" {
if !isSafOutput && (lyricsMode == "external" || lyricsMode == "both") {
GoLog("[Tidal] Saving external LRC file for M4A (mode: %s)...\n", lyricsMode)
if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Tidal] Warning: failed to save LRC file: %v\n", lrcErr)
@@ -1687,7 +1817,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
if !isSafOutput {
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
}
bitDepth := downloadInfo.BitDepth
sampleRate := downloadInfo.SampleRate
@@ -1695,15 +1827,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
if quality == "HIGH" {
bitDepth = 0
sampleRate = 44100
if parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsMode := req.LyricsMode
if lyricsMode == "" {
lyricsMode = "embed"
}
if lyricsMode == "embed" || lyricsMode == "both" {
lyricsLRC = parallelResult.LyricsLRC
}
}
}
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
}
return TidalDownloadResult{
+566
View File
@@ -0,0 +1,566 @@
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
package gobackend
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type YouTubeDownloader struct {
client *http.Client
apiURL string
mu sync.Mutex
}
var (
globalYouTubeDownloader *YouTubeDownloader
youtubeDownloaderOnce sync.Once
)
type YouTubeQuality string
const (
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
)
type CobaltRequest struct {
URL string `json:"url"`
AudioBitrate string `json:"audioBitrate,omitempty"`
AudioFormat string `json:"audioFormat,omitempty"`
DownloadMode string `json:"downloadMode,omitempty"`
FilenameStyle string `json:"filenameStyle,omitempty"`
DisableMetadata bool `json:"disableMetadata,omitempty"`
}
type CobaltResponse struct {
Status string `json:"status"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
Error *struct {
Code string `json:"code"`
Context *struct {
Service string `json:"service,omitempty"`
Limit int `json:"limit,omitempty"`
} `json:"context,omitempty"`
} `json:"error,omitempty"`
}
type YouTubeDownloadResult struct {
FilePath string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
Format string // "opus" or "mp3"
Bitrate int
LyricsLRC string
CoverData []byte
}
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
apiURL: "https://api.qwkuns.me",
}
})
return globalYouTubeDownloader
}
// SearchYouTube returns a YouTube Music search URL for the given track
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
searchQuery := url.QueryEscape(query)
GoLog("[YouTube] Search query: %s\n", query)
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
return youtubeMusicURL, nil
}
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
y.mu.Lock()
defer y.mu.Unlock()
var audioFormat string
var audioBitrate string
switch quality {
case YouTubeQualityOpus256:
audioFormat = "opus"
audioBitrate = "256"
case YouTubeQualityMP3320:
audioFormat = "mp3"
audioBitrate = "320"
default:
audioFormat = "mp3"
audioBitrate = "320"
}
// Try SpotubeDL first (primary)
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
if extractErr == nil {
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
videoID, audioFormat, audioBitrate)
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
if err == nil {
return resp, nil
}
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
} else {
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
}
// Fallback: direct Cobalt API (api.qwkuns.me)
cobaltURL := toYouTubeMusicURL(youtubeURL)
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
cobaltURL, audioFormat, audioBitrate)
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
if err != nil {
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
}
return resp, nil
}
// requestCobaltDirect sends a download request to the primary Cobalt API.
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{
URL: videoURL,
AudioFormat: audioFormat,
AudioBitrate: audioBitrate,
DownloadMode: "audio",
FilenameStyle: "basic",
DisableMetadata: true,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("cobalt API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
}
var cobaltResp CobaltResponse
if err := json.Unmarshal(body, &cobaltResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
}
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
}
if cobaltResp.URL == "" {
return nil, fmt.Errorf("no download URL in response")
}
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
return &cobaltResp, nil
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
videoID, audioFormat, audioBitrate)
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("spotubedl request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
}
if result.URL == "" {
return nil, fmt.Errorf("no download URL from spotubedl")
}
GoLog("[YouTube] Got download URL from SpotubeDL\n")
return &CobaltResponse{
Status: "tunnel",
URL: result.URL,
}, nil
}
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[YouTube] Download completed: %d bytes written\n", written)
return nil
}
func BuildYouTubeSearchURL(trackName, artistName string) string {
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
}
func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
func isYouTubeVideoID(s string) bool {
if len(s) != 11 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
func IsYouTubeURL(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "youtube.com") ||
strings.Contains(lower, "youtu.be") ||
strings.Contains(lower, "music.youtube.com")
}
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
func toYouTubeMusicURL(rawURL string) string {
videoID, err := ExtractYouTubeVideoID(rawURL)
if err != nil {
return rawURL
}
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func ExtractYouTubeVideoID(urlStr string) (string, error) {
if strings.Contains(urlStr, "youtu.be/") {
parts := strings.Split(urlStr, "youtu.be/")
if len(parts) >= 2 {
videoID := strings.Split(parts[1], "?")[0]
videoID = strings.Split(videoID, "&")[0]
return strings.TrimSpace(videoID), nil
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
// /watch?v=
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
// /embed/
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
// /v/
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
return "", fmt.Errorf("could not extract video ID from URL")
}
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader()
var quality YouTubeQuality
switch strings.ToLower(req.Quality) {
case "opus_256", "opus256", "opus":
quality = YouTubeQualityOpus256
case "mp3_320", "mp3320", "mp3":
quality = YouTubeQualityMP3320
default:
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
}
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
var youtubeURL string
var lookupErr error
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
}
// Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
}
}
// Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
}
}
// Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
youtubeURL = availability.YouTubeURL
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
} else if isrcErr != nil {
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
}
}
// Cobalt requires direct video URLs, not search URLs
if youtubeURL == "" {
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
}
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
if err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
var ext string
var format string
var bitrate int
switch quality {
case YouTubeQualityOpus256:
ext = ".opus"
format = "opus"
bitrate = 256
case YouTubeQualityMP3320:
ext = ".mp3"
format = "mp3"
bitrate = 320
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ext
var outputPath string
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
outputPath = req.OutputDir + "/" + filename
}
GoLog("[YouTube] Downloading to: %s\n", outputPath)
// Parallel fetch cover art + lyrics
var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
lyricsLRC := ""
var coverData []byte
if parallelResult != nil {
if parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
}
if parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
}
}
return YouTubeDownloadResult{
FilePath: outputPath,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Format: format,
Bitrate: bitrate,
LyricsLRC: lyricsLRC,
CoverData: coverData,
}, nil
}
+24
View File
@@ -217,6 +217,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "editFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let metadataJson = args["metadata_json"] as? String ?? "{}"
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
if let error = error { throw error }
return response
case "searchDeezerAll":
let args = call.arguments as! [String: Any]
let query = args["query"] as! String
@@ -639,6 +647,14 @@ import Gobackend // Import Go framework
let response = GobackendRunPostProcessingJSON(filePath, metadataJson, &error)
if let error = error { throw error }
return response
case "runPostProcessingV2":
let args = call.arguments as! [String: Any]
let inputJson = args["input"] as? String ?? ""
let metadataJson = args["metadata"] as? String ?? ""
let response = GobackendRunPostProcessingV2JSON(inputJson, metadataJson, &error)
if let error = error { throw error }
return response
case "getPostProcessingProviders":
let response = GobackendGetPostProcessingProvidersJSON(&error)
@@ -715,6 +731,14 @@ import Gobackend // Import Go framework
if let error = error { throw error }
return response
case "scanLibraryFolderIncremental":
let args = call.arguments as! [String: Any]
let folderPath = args["folder_path"] as! String
let existingFiles = args["existing_files"] as? String ?? "{}"
let response = GobackendScanLibraryFolderIncrementalJSON(folderPath, existingFiles, &error)
if let error = error { throw error }
return response
case "getLibraryScanProgress":
let response = GobackendGetLibraryScanProgressJSON()
return response
+17 -1
View File
@@ -4,15 +4,27 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/screens/main_shell.dart';
import 'package:spotiflac_android/screens/setup_screen.dart';
import 'package:spotiflac_android/screens/tutorial_screen.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
final _routerProvider = Provider<GoRouter>((ref) {
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
final hasCompletedTutorial = ref.watch(settingsProvider.select((s) => s.hasCompletedTutorial));
// Determine initial location based on app state
String initialLocation;
if (isFirstLaunch) {
initialLocation = '/setup';
} else if (!hasCompletedTutorial) {
initialLocation = '/tutorial';
} else {
initialLocation = '/';
}
return GoRouter(
initialLocation: isFirstLaunch ? '/setup' : '/',
initialLocation: initialLocation,
routes: [
GoRoute(
path: '/',
@@ -22,6 +34,10 @@ final _routerProvider = Provider<GoRouter>((ref) {
path: '/setup',
builder: (context, state) => const SetupScreen(),
),
GoRoute(
path: '/tutorial',
builder: (context, state) => const TutorialScreen(),
),
],
);
});
+4 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '3.4.0';
static const String buildNumber = '72';
static const String version = '3.6.0';
static const String buildNumber = '77';
static const String fullVersion = '$version+$buildNumber';
@@ -17,4 +17,6 @@ class AppInfo {
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
static const String bmacUrl = 'https://buymeacoffee.com/zarzet';
static const String githubSponsorsUrl = 'https://github.com/sponsors/zarzet/';
}
+714
View File
@@ -712,6 +712,12 @@ abstract class AppLocalizations {
/// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'**
String get optionsSpotifyWarning;
/// Warning about Spotify API deprecation
///
/// In en, this message translates to:
/// **'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'**
String get optionsSpotifyDeprecationWarning;
/// Extensions page title
///
/// In en, this message translates to:
@@ -988,6 +994,18 @@ abstract class AppLocalizations {
/// **'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!'**
String get aboutDabMusicDesc;
/// Name of SpotiSaver API service - DO NOT TRANSLATE
///
/// In en, this message translates to:
/// **'SpotiSaver'**
String get aboutSpotiSaver;
/// Credit for SpotiSaver API
///
/// In en, this message translates to:
/// **'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!'**
String get aboutSpotiSaverDesc;
/// App description in header card
///
/// In en, this message translates to:
@@ -3484,6 +3502,12 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'**
String get qualityNote;
/// Note for YouTube service explaining lossy-only quality
///
/// In en, this message translates to:
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Setting - show quality picker
///
/// In en, this message translates to:
@@ -3508,6 +3532,24 @@ abstract class AppLocalizations {
/// **'Album Folder Structure'**
String get downloadAlbumFolderStructure;
/// Setting - choose whether artist folders use Album Artist or Track Artist
///
/// In en, this message translates to:
/// **'Use Album Artist for folders'**
String get downloadUseAlbumArtistForFolders;
/// Subtitle when Album Artist is used for folder naming
///
/// In en, this message translates to:
/// **'Artist folders use Album Artist when available'**
String get downloadUseAlbumArtistForFoldersAlbumSubtitle;
/// Subtitle when Track Artist is used for folder naming
///
/// In en, this message translates to:
/// **'Artist folders use Track Artist only'**
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
/// Setting - output file format
///
/// In en, this message translates to:
@@ -3922,6 +3964,18 @@ abstract class AppLocalizations {
/// **'Playlist'**
String get recentTypePlaylist;
/// Empty state text for recent access list
///
/// In en, this message translates to:
/// **'No recent items yet'**
String get recentEmpty;
/// Button label to unhide hidden downloads in recent access
///
/// In en, this message translates to:
/// **'Show All Downloads'**
String get recentShowAllDownloads;
/// Snackbar message when tapping playlist in recent access
///
/// In en, this message translates to:
@@ -4090,6 +4144,18 @@ abstract class AppLocalizations {
/// **'Scan music & detect duplicates'**
String get settingsLocalLibrarySubtitle;
/// Settings menu item - cache management
///
/// In en, this message translates to:
/// **'Storage & Cache'**
String get settingsCache;
/// Subtitle for cache management menu
///
/// In en, this message translates to:
/// **'View size and clear cached data'**
String get settingsCacheSubtitle;
/// Library settings page title
///
/// In en, this message translates to:
@@ -4396,6 +4462,24 @@ abstract class AppLocalizations {
/// **'This Year'**
String get libraryFilterDateYear;
/// Filter section - sort order
///
/// In en, this message translates to:
/// **'Sort'**
String get libraryFilterSort;
/// Sort option - newest first
///
/// In en, this message translates to:
/// **'Latest'**
String get libraryFilterSortLatest;
/// Sort option - oldest first
///
/// In en, this message translates to:
/// **'Oldest'**
String get libraryFilterSortOldest;
/// Badge showing number of active filters
///
/// In en, this message translates to:
@@ -4419,6 +4503,636 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'{count, plural, =1{1 hour ago} other{{count} hours ago}}'**
String timeHoursAgo(int count);
/// Dialog title when switching storage mode
///
/// In en, this message translates to:
/// **'Switch Storage Mode'**
String get storageSwitchTitle;
/// Dialog title when switching to SAF
///
/// In en, this message translates to:
/// **'Switch to SAF Storage?'**
String get storageSwitchToSafTitle;
/// Dialog title when switching to app storage
///
/// In en, this message translates to:
/// **'Switch to App Storage?'**
String get storageSwitchToAppTitle;
/// Explanation when switching to SAF
///
/// In en, this message translates to:
/// **'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.'**
String get storageSwitchToSafMessage;
/// Explanation when switching to app storage
///
/// In en, this message translates to:
/// **'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.'**
String get storageSwitchToAppMessage;
/// Section header for existing downloads info
///
/// In en, this message translates to:
/// **'Existing Downloads'**
String get storageSwitchExistingDownloads;
/// Info about existing downloads count
///
/// In en, this message translates to:
/// **'{count} tracks in {mode} storage'**
String storageSwitchExistingDownloadsInfo(int count, String mode);
/// Section header for new downloads info
///
/// In en, this message translates to:
/// **'New Downloads'**
String get storageSwitchNewDownloads;
/// Shows where new downloads will go
///
/// In en, this message translates to:
/// **'Will be saved to: {location}'**
String storageSwitchNewDownloadsLocation(String location);
/// Button to proceed with storage switch
///
/// In en, this message translates to:
/// **'Continue'**
String get storageSwitchContinue;
/// Button to select SAF folder
///
/// In en, this message translates to:
/// **'Select SAF Folder'**
String get storageSwitchSelectFolder;
/// Label for app storage mode
///
/// In en, this message translates to:
/// **'App Storage'**
String get storageAppStorage;
/// Label for SAF storage mode
///
/// In en, this message translates to:
/// **'SAF Storage'**
String get storageSafStorage;
/// Badge showing storage mode for a track
///
/// In en, this message translates to:
/// **'Storage: {mode}'**
String storageModeBadge(String mode);
/// Section title for storage stats
///
/// In en, this message translates to:
/// **'Storage Statistics'**
String get storageStatsTitle;
/// Count of tracks in app storage
///
/// In en, this message translates to:
/// **'{count} tracks in App Storage'**
String storageStatsAppCount(int count);
/// Count of tracks in SAF storage
///
/// In en, this message translates to:
/// **'{count} tracks in SAF Storage'**
String storageStatsSafCount(int count);
/// Info when user has files in both storage modes
///
/// In en, this message translates to:
/// **'Your files are stored in multiple locations'**
String get storageModeInfo;
/// Tutorial welcome page title
///
/// In en, this message translates to:
/// **'Welcome to SpotiFLAC!'**
String get tutorialWelcomeTitle;
/// Tutorial welcome page description
///
/// In en, this message translates to:
/// **'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.'**
String get tutorialWelcomeDesc;
/// Tutorial welcome tip 1
///
/// In en, this message translates to:
/// **'Download music from Spotify, Deezer, or paste any supported URL'**
String get tutorialWelcomeTip1;
/// Tutorial welcome tip 2
///
/// In en, this message translates to:
/// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'**
String get tutorialWelcomeTip2;
/// Tutorial welcome tip 3
///
/// In en, this message translates to:
/// **'Automatic metadata, cover art, and lyrics embedding'**
String get tutorialWelcomeTip3;
/// Tutorial search page title
///
/// In en, this message translates to:
/// **'Finding Music'**
String get tutorialSearchTitle;
/// Tutorial search page description
///
/// In en, this message translates to:
/// **'There are two easy ways to find music you want to download.'**
String get tutorialSearchDesc;
/// Tutorial search tip 1
///
/// In en, this message translates to:
/// **'Paste a Spotify or Deezer URL directly in the search box'**
String get tutorialSearchTip1;
/// Tutorial search tip 2
///
/// In en, this message translates to:
/// **'Or type the song name, artist, or album to search'**
String get tutorialSearchTip2;
/// Tutorial search tip 3
///
/// In en, this message translates to:
/// **'Supports tracks, albums, playlists, and artist pages'**
String get tutorialSearchTip3;
/// Tutorial download page title
///
/// In en, this message translates to:
/// **'Downloading Music'**
String get tutorialDownloadTitle;
/// Tutorial download page description
///
/// In en, this message translates to:
/// **'Downloading music is simple and fast. Here\'s how it works.'**
String get tutorialDownloadDesc;
/// Tutorial download tip 1
///
/// In en, this message translates to:
/// **'Tap the download button next to any track to start downloading'**
String get tutorialDownloadTip1;
/// Tutorial download tip 2
///
/// In en, this message translates to:
/// **'Choose your preferred quality (FLAC, Hi-Res, or MP3)'**
String get tutorialDownloadTip2;
/// Tutorial download tip 3
///
/// In en, this message translates to:
/// **'Download entire albums or playlists with one tap'**
String get tutorialDownloadTip3;
/// Tutorial library page title
///
/// In en, this message translates to:
/// **'Your Library'**
String get tutorialLibraryTitle;
/// Tutorial library page description
///
/// In en, this message translates to:
/// **'All your downloaded music is organized in the Library tab.'**
String get tutorialLibraryDesc;
/// Tutorial library tip 1
///
/// In en, this message translates to:
/// **'View download progress and queue in the Library tab'**
String get tutorialLibraryTip1;
/// Tutorial library tip 2
///
/// In en, this message translates to:
/// **'Tap any track to play it with your music player'**
String get tutorialLibraryTip2;
/// Tutorial library tip 3
///
/// In en, this message translates to:
/// **'Switch between list and grid view for better browsing'**
String get tutorialLibraryTip3;
/// Tutorial extensions page title
///
/// In en, this message translates to:
/// **'Extensions'**
String get tutorialExtensionsTitle;
/// Tutorial extensions page description
///
/// In en, this message translates to:
/// **'Extend the app\'s capabilities with community extensions.'**
String get tutorialExtensionsDesc;
/// Tutorial extensions tip 1
///
/// In en, this message translates to:
/// **'Browse the Store tab to discover useful extensions'**
String get tutorialExtensionsTip1;
/// Tutorial extensions tip 2
///
/// In en, this message translates to:
/// **'Add new download providers or search sources'**
String get tutorialExtensionsTip2;
/// Tutorial extensions tip 3
///
/// In en, this message translates to:
/// **'Get lyrics, enhanced metadata, and more features'**
String get tutorialExtensionsTip3;
/// Tutorial settings page title
///
/// In en, this message translates to:
/// **'Customize Your Experience'**
String get tutorialSettingsTitle;
/// Tutorial settings page description
///
/// In en, this message translates to:
/// **'Personalize the app in Settings to match your preferences.'**
String get tutorialSettingsDesc;
/// Tutorial settings tip 1
///
/// In en, this message translates to:
/// **'Change download location and folder organization'**
String get tutorialSettingsTip1;
/// Tutorial settings tip 2
///
/// In en, this message translates to:
/// **'Set default audio quality and format preferences'**
String get tutorialSettingsTip2;
/// Tutorial settings tip 3
///
/// In en, this message translates to:
/// **'Customize app theme and appearance'**
String get tutorialSettingsTip3;
/// Tutorial completion message
///
/// In en, this message translates to:
/// **'You\'re all set! Start downloading your favorite music now.'**
String get tutorialReadyMessage;
/// Example label in tutorial
///
/// In en, this message translates to:
/// **'EXAMPLE'**
String get tutorialExample;
/// Button to force a complete rescan of library
///
/// In en, this message translates to:
/// **'Force Full Scan'**
String get libraryForceFullScan;
/// Subtitle for force full scan button
///
/// In en, this message translates to:
/// **'Rescan all files, ignoring cache'**
String get libraryForceFullScanSubtitle;
/// Button to remove history entries for deleted files
///
/// In en, this message translates to:
/// **'Cleanup Orphaned Downloads'**
String get cleanupOrphanedDownloads;
/// Subtitle for orphaned cleanup button
///
/// In en, this message translates to:
/// **'Remove history entries for files that no longer exist'**
String get cleanupOrphanedDownloadsSubtitle;
/// Snackbar after orphan cleanup
///
/// In en, this message translates to:
/// **'Removed {count} orphaned entries from history'**
String cleanupOrphanedDownloadsResult(int count);
/// Snackbar when no orphans found
///
/// In en, this message translates to:
/// **'No orphaned entries found'**
String get cleanupOrphanedDownloadsNone;
/// Cache management page title
///
/// In en, this message translates to:
/// **'Storage & Cache'**
String get cacheTitle;
/// Heading for cache summary card
///
/// In en, this message translates to:
/// **'Cache overview'**
String get cacheSummaryTitle;
/// Helper text for cache summary card
///
/// In en, this message translates to:
/// **'Clearing cache will not remove downloaded music files.'**
String get cacheSummarySubtitle;
/// Total cache size shown in summary
///
/// In en, this message translates to:
/// **'Estimated cache usage: {size}'**
String cacheEstimatedTotal(String size);
/// Section header for cache entries
///
/// In en, this message translates to:
/// **'Cached Data'**
String get cacheSectionStorage;
/// Section header for cleanup actions
///
/// In en, this message translates to:
/// **'Maintenance'**
String get cacheSectionMaintenance;
/// Cache item title for app cache directory
///
/// In en, this message translates to:
/// **'App cache directory'**
String get cacheAppDirectory;
/// Description of what app cache directory contains
///
/// In en, this message translates to:
/// **'HTTP responses, WebView data, and other temporary app data.'**
String get cacheAppDirectoryDesc;
/// Cache item title for temporary files directory
///
/// In en, this message translates to:
/// **'Temporary directory'**
String get cacheTempDirectory;
/// Description of what temporary directory contains
///
/// In en, this message translates to:
/// **'Temporary files from downloads and audio conversion.'**
String get cacheTempDirectoryDesc;
/// Cache item title for persistent cover images
///
/// In en, this message translates to:
/// **'Cover image cache'**
String get cacheCoverImage;
/// Description of what cover image cache contains
///
/// In en, this message translates to:
/// **'Downloaded album and track cover art. Will re-download when viewed.'**
String get cacheCoverImageDesc;
/// Cache item title for local library cover art images
///
/// In en, this message translates to:
/// **'Library cover cache'**
String get cacheLibraryCover;
/// Description of what library cover cache contains
///
/// In en, this message translates to:
/// **'Cover art extracted from local music files. Will re-extract on next scan.'**
String get cacheLibraryCoverDesc;
/// Cache item title for explore home feed cache
///
/// In en, this message translates to:
/// **'Explore feed cache'**
String get cacheExploreFeed;
/// Description of what explore feed cache contains
///
/// In en, this message translates to:
/// **'Explore tab content (new releases, trending). Will refresh on next visit.'**
String get cacheExploreFeedDesc;
/// Cache item title for track ID lookup cache
///
/// In en, this message translates to:
/// **'Track lookup cache'**
String get cacheTrackLookup;
/// Description of what track lookup cache contains
///
/// In en, this message translates to:
/// **'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'**
String get cacheTrackLookupDesc;
/// Description of what cleanup unused data does
///
/// In en, this message translates to:
/// **'Remove orphaned download history and library entries for missing files.'**
String get cacheCleanupUnusedDesc;
/// Label when cache category has no data
///
/// In en, this message translates to:
/// **'No cached data'**
String get cacheNoData;
/// Cache size and file count
///
/// In en, this message translates to:
/// **'{size} in {count} files'**
String cacheSizeWithFiles(String size, int count);
/// Cache size only
///
/// In en, this message translates to:
/// **'{size}'**
String cacheSizeOnly(String size);
/// Track cache entry count
///
/// In en, this message translates to:
/// **'{count} entries'**
String cacheEntries(int count);
/// Snackbar after clearing selected cache
///
/// In en, this message translates to:
/// **'Cleared: {target}'**
String cacheClearSuccess(String target);
/// Dialog title before clearing one cache category
///
/// In en, this message translates to:
/// **'Clear cache?'**
String get cacheClearConfirmTitle;
/// Dialog message before clearing selected cache
///
/// In en, this message translates to:
/// **'This will clear cached data for {target}. Downloaded music files will not be deleted.'**
String cacheClearConfirmMessage(String target);
/// Dialog title before clearing all caches
///
/// In en, this message translates to:
/// **'Clear all cache?'**
String get cacheClearAllConfirmTitle;
/// Dialog message before clearing all caches
///
/// In en, this message translates to:
/// **'This will clear all cache categories on this page. Downloaded music files will not be deleted.'**
String get cacheClearAllConfirmMessage;
/// Button label to clear all caches
///
/// In en, this message translates to:
/// **'Clear all cache'**
String get cacheClearAll;
/// Action title for cleaning unused entries
///
/// In en, this message translates to:
/// **'Cleanup unused data'**
String get cacheCleanupUnused;
/// Subtitle for cleanup unused data action
///
/// In en, this message translates to:
/// **'Remove orphaned download history and missing library entries'**
String get cacheCleanupUnusedSubtitle;
/// Snackbar after unused data cleanup
///
/// In en, this message translates to:
/// **'Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries'**
String cacheCleanupResult(int downloadCount, int libraryCount);
/// Button label to refresh cache statistics
///
/// In en, this message translates to:
/// **'Refresh stats'**
String get cacheRefreshStats;
/// Menu action - save album cover art as file
///
/// In en, this message translates to:
/// **'Save Cover Art'**
String get trackSaveCoverArt;
/// Subtitle for save cover art action
///
/// In en, this message translates to:
/// **'Save album art as .jpg file'**
String get trackSaveCoverArtSubtitle;
/// Menu action - save lyrics as .lrc file
///
/// In en, this message translates to:
/// **'Save Lyrics (.lrc)'**
String get trackSaveLyrics;
/// Subtitle for save lyrics action
///
/// In en, this message translates to:
/// **'Fetch and save lyrics as .lrc file'**
String get trackSaveLyricsSubtitle;
/// 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
+433
View File
@@ -352,6 +352,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Erweiterungen';
@@ -504,6 +508,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get aboutDabMusicDesc =>
'Die beste Qobuz-Streaming-API. Hi-Res-Downloads wären ohne diese nicht möglich!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.';
@@ -1922,6 +1933,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1934,6 +1949,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2169,6 +2195,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2275,6 +2307,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2442,6 +2480,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2471,4 +2518,390 @@ class AppLocalizationsDe extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+433
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2456,4 +2503,390 @@ class AppLocalizationsEn extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+433
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsEs extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2456,6 +2503,392 @@ class AppLocalizationsEs extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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`).
+433
View File
@@ -343,6 +343,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsFr extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2456,4 +2503,390 @@ class AppLocalizationsFr extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+433
View File
@@ -343,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsHi extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2456,4 +2503,390 @@ class AppLocalizationsHi extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+439
View File
@@ -347,6 +347,10 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
@override
String get extensionsTitle => 'Ekstensi';
@@ -496,6 +500,13 @@ class AppLocalizationsId extends AppLocalizations {
String get aboutDabMusicDesc =>
'API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.';
@@ -1919,6 +1930,10 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1931,6 +1946,18 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get downloadUseAlbumArtistForFolders =>
'Gunakan Album Artist untuk folder';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Folder artis memakai Album Artist jika tersedia';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Folder artis hanya memakai Track Artist';
@override
String get downloadSaveFormat => 'Simpan Format';
@@ -2167,6 +2194,12 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'Belum ada item terbaru';
@override
String get recentShowAllDownloads => 'Tampilkan Semua Download';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2273,6 +2306,12 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Penyimpanan & Cache';
@override
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
@override
String get libraryTitle => 'Local Library';
@@ -2440,6 +2479,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2469,4 +2517,395 @@ class AppLocalizationsId extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Selamat Datang di SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.';
@override
String get tutorialWelcomeTip1 =>
'Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung';
@override
String get tutorialWelcomeTip2 =>
'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Metadata, cover art, dan lirik otomatis tertanam';
@override
String get tutorialSearchTitle => 'Mencari Musik';
@override
String get tutorialSearchDesc =>
'Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.';
@override
String get tutorialSearchTip1 =>
'Tempel URL Spotify atau Deezer langsung di kotak pencarian';
@override
String get tutorialSearchTip2 =>
'Atau ketik nama lagu, artis, atau album untuk mencari';
@override
String get tutorialSearchTip3 =>
'Mendukung lagu, album, playlist, dan halaman artis';
@override
String get tutorialDownloadTitle => 'Mengunduh Musik';
@override
String get tutorialDownloadDesc =>
'Mengunduh musik itu mudah dan cepat. Begini caranya.';
@override
String get tutorialDownloadTip1 =>
'Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh';
@override
String get tutorialDownloadTip2 =>
'Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)';
@override
String get tutorialDownloadTip3 =>
'Unduh seluruh album atau playlist dengan satu ketukan';
@override
String get tutorialLibraryTitle => 'Perpustakaan Anda';
@override
String get tutorialLibraryDesc =>
'Semua musik yang Anda unduh terorganisir di tab Perpustakaan.';
@override
String get tutorialLibraryTip1 =>
'Lihat progres unduhan dan antrian di tab Perpustakaan';
@override
String get tutorialLibraryTip2 =>
'Ketuk lagu mana pun untuk memutarnya dengan pemutar musik';
@override
String get tutorialLibraryTip3 =>
'Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik';
@override
String get tutorialExtensionsTitle => 'Ekstensi';
@override
String get tutorialExtensionsDesc =>
'Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.';
@override
String get tutorialExtensionsTip1 =>
'Jelajahi tab Toko untuk menemukan ekstensi berguna';
@override
String get tutorialExtensionsTip2 =>
'Tambahkan provider unduhan atau sumber pencarian baru';
@override
String get tutorialExtensionsTip3 =>
'Dapatkan lirik, metadata lebih baik, dan fitur lainnya';
@override
String get tutorialSettingsTitle => 'Sesuaikan Pengalaman Anda';
@override
String get tutorialSettingsDesc =>
'Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.';
@override
String get tutorialSettingsTip1 =>
'Ubah lokasi unduhan dan organisasi folder';
@override
String get tutorialSettingsTip2 =>
'Atur kualitas audio dan preferensi format default';
@override
String get tutorialSettingsTip3 => 'Sesuaikan tema dan tampilan aplikasi';
@override
String get tutorialReadyMessage =>
'Anda siap! Mulai unduh musik favorit Anda sekarang.';
@override
String get tutorialExample => 'CONTOH';
@override
String get libraryForceFullScan => 'Pindai Ulang Penuh';
@override
String get libraryForceFullScanSubtitle =>
'Pindai ulang semua file, abaikan cache';
@override
String get cleanupOrphanedDownloads => 'Bersihkan Entri Unduhan Tidak Valid';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Hapus entri riwayat untuk file yang tidak ada lagi';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Menghapus $count entri unduhan tidak valid dari riwayat';
}
@override
String get cleanupOrphanedDownloadsNone =>
'Tidak ada entri unduhan tidak valid';
@override
String get cacheTitle => 'Penyimpanan & Cache';
@override
String get cacheSummaryTitle => 'Ringkasan cache';
@override
String get cacheSummarySubtitle =>
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimasi penggunaan cache: $size';
}
@override
String get cacheSectionStorage => 'Data Cache';
@override
String get cacheSectionMaintenance => 'Perawatan';
@override
String get cacheAppDirectory => 'Direktori cache aplikasi';
@override
String get cacheAppDirectoryDesc =>
'Respons HTTP, data WebView, dan data sementara aplikasi.';
@override
String get cacheTempDirectory => 'Direktori sementara';
@override
String get cacheTempDirectoryDesc =>
'File sementara dari proses download dan konversi audio.';
@override
String get cacheCoverImage => 'Cache gambar cover';
@override
String get cacheCoverImageDesc =>
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
@override
String get cacheLibraryCover => 'Cache cover library';
@override
String get cacheLibraryCoverDesc =>
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
@override
String get cacheExploreFeed => 'Cache feed Explore';
@override
String get cacheExploreFeedDesc =>
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
@override
String get cacheTrackLookup => 'Cache pencocokan lagu';
@override
String get cacheTrackLookupDesc =>
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
@override
String get cacheCleanupUnusedDesc =>
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
@override
String get cacheNoData => 'Tidak ada data cache';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size dalam $count file';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entri';
}
@override
String cacheClearSuccess(String target) {
return 'Berhasil dibersihkan: $target';
}
@override
String get cacheClearConfirmTitle => 'Bersihkan cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
}
@override
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
@override
String get cacheClearAllConfirmMessage =>
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
@override
String get cacheClearAll => 'Bersihkan semua cache';
@override
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
@override
String get cacheCleanupUnusedSubtitle =>
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
}
@override
String get cacheRefreshStats => 'Segarkan statistik';
@override
String get trackSaveCoverArt => 'Simpan Cover Art';
@override
String get trackSaveCoverArtSubtitle =>
'Simpan cover album sebagai file .jpg';
@override
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
@override
String get trackSaveLyricsSubtitle =>
'Ambil dan simpan lirik sebagai file .lrc';
@override
String get trackReEnrich => 'Perkaya Ulang Metadata';
@override
String get trackReEnrichSubtitle =>
'Tanamkan ulang metadata tanpa mengunduh ulang';
@override
String get trackReEnrichOnlineSubtitle =>
'Cari metadata dari internet dan tanamkan ke file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art disimpan ke $fileName';
}
@override
String get trackCoverNoSource => 'Tidak ada sumber cover art';
@override
String trackLyricsSaved(String fileName) {
return 'Lirik disimpan ke $fileName';
}
@override
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
@override
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
@override
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
@override
String get trackReEnrichFfmpegFailed =>
'Gagal menanamkan metadata via FFmpeg';
@override
String trackSaveFailed(String error) {
return 'Gagal: $error';
}
}
+433
View File
@@ -340,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => '拡張';
@@ -487,6 +491,13 @@ class AppLocalizationsJa extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。';
@@ -1895,6 +1906,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1907,6 +1922,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => '形式を保存';
@@ -2140,6 +2166,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get recentTypePlaylist => 'プレイリスト';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'プレイリスト: $name';
@@ -2246,6 +2278,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2413,6 +2451,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2442,4 +2489,390 @@ class AppLocalizationsJa extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+433
View File
@@ -343,6 +343,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsKo extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2456,4 +2503,390 @@ class AppLocalizationsKo extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+433
View File
@@ -343,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsNl extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2456,4 +2503,390 @@ class AppLocalizationsNl extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+433
View File
@@ -343,6 +343,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsPt extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2456,6 +2503,392 @@ class AppLocalizationsPt extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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`).
+433
View File
@@ -354,6 +354,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Расширения';
@@ -504,6 +508,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get aboutDabMusicDesc =>
'Лучший API для стриминга Qobuz. Без него загрузка файлов в высоком разрешении была бы невозможна!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.';
@@ -1945,6 +1956,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1957,6 +1972,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Структура папок альбома';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Формат сохранения';
@@ -2199,6 +2225,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get recentTypePlaylist => 'Плейлист';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Плейлист: $name';
@@ -2306,6 +2338,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2473,6 +2511,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2502,4 +2549,390 @@ class AppLocalizationsRu extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+433
View File
@@ -348,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Eklentiler';
@@ -498,6 +502,13 @@ class AppLocalizationsTr extends AppLocalizations {
String get aboutDabMusicDesc =>
'En iyi Qobuz streaming API\'ı. Yüksek kalite indirmeler bunun sayesinde!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.';
@@ -1922,6 +1933,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1934,6 +1949,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2169,6 +2195,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2275,6 +2307,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2442,6 +2480,15 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2471,4 +2518,390 @@ class AppLocalizationsTr extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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';
}
}
+433
View File
@@ -343,6 +343,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@@ -491,6 +495,13 @@ class AppLocalizationsZh extends AppLocalizations {
String get aboutDabMusicDesc =>
'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!';
@override
String get aboutSpotiSaver => 'SpotiSaver';
@override
String get aboutSpotiSaverDesc =>
'Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!';
@override
String get aboutAppDescription =>
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.';
@@ -1907,6 +1918,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote =>
'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override
String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1919,6 +1934,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@@ -2154,6 +2180,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@@ -2260,6 +2292,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@@ -2427,6 +2465,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get libraryFilterDateYear => 'This Year';
@override
String get libraryFilterSort => 'Sort';
@override
String get libraryFilterSortLatest => 'Latest';
@override
String get libraryFilterSortOldest => 'Oldest';
@override
String libraryFilterActive(int count) {
return '$count filter(s) active';
@@ -2456,6 +2503,392 @@ class AppLocalizationsZh extends AppLocalizations {
);
return '$_temp0';
}
@override
String get storageSwitchTitle => 'Switch Storage Mode';
@override
String get storageSwitchToSafTitle => 'Switch to SAF Storage?';
@override
String get storageSwitchToAppTitle => 'Switch to App Storage?';
@override
String get storageSwitchToSafMessage =>
'Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.';
@override
String get storageSwitchToAppMessage =>
'Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.';
@override
String get storageSwitchExistingDownloads => 'Existing Downloads';
@override
String storageSwitchExistingDownloadsInfo(int count, String mode) {
return '$count tracks in $mode storage';
}
@override
String get storageSwitchNewDownloads => 'New Downloads';
@override
String storageSwitchNewDownloadsLocation(String location) {
return 'Will be saved to: $location';
}
@override
String get storageSwitchContinue => 'Continue';
@override
String get storageSwitchSelectFolder => 'Select SAF Folder';
@override
String get storageAppStorage => 'App Storage';
@override
String get storageSafStorage => 'SAF Storage';
@override
String storageModeBadge(String mode) {
return 'Storage: $mode';
}
@override
String get storageStatsTitle => 'Storage Statistics';
@override
String storageStatsAppCount(int count) {
return '$count tracks in App Storage';
}
@override
String storageStatsSafCount(int count) {
return '$count tracks in SAF Storage';
}
@override
String get storageModeInfo => 'Your files are stored in multiple locations';
@override
String get tutorialWelcomeTitle => 'Welcome to SpotiFLAC!';
@override
String get tutorialWelcomeDesc =>
'Let\'s learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.';
@override
String get tutorialWelcomeTip1 =>
'Download music from Spotify, Deezer, or paste any supported URL';
@override
String get tutorialWelcomeTip2 =>
'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music';
@override
String get tutorialWelcomeTip3 =>
'Automatic metadata, cover art, and lyrics embedding';
@override
String get tutorialSearchTitle => 'Finding Music';
@override
String get tutorialSearchDesc =>
'There are two easy ways to find music you want to download.';
@override
String get tutorialSearchTip1 =>
'Paste a Spotify or Deezer URL directly in the search box';
@override
String get tutorialSearchTip2 =>
'Or type the song name, artist, or album to search';
@override
String get tutorialSearchTip3 =>
'Supports tracks, albums, playlists, and artist pages';
@override
String get tutorialDownloadTitle => 'Downloading Music';
@override
String get tutorialDownloadDesc =>
'Downloading music is simple and fast. Here\'s how it works.';
@override
String get tutorialDownloadTip1 =>
'Tap the download button next to any track to start downloading';
@override
String get tutorialDownloadTip2 =>
'Choose your preferred quality (FLAC, Hi-Res, or MP3)';
@override
String get tutorialDownloadTip3 =>
'Download entire albums or playlists with one tap';
@override
String get tutorialLibraryTitle => 'Your Library';
@override
String get tutorialLibraryDesc =>
'All your downloaded music is organized in the Library tab.';
@override
String get tutorialLibraryTip1 =>
'View download progress and queue in the Library tab';
@override
String get tutorialLibraryTip2 =>
'Tap any track to play it with your music player';
@override
String get tutorialLibraryTip3 =>
'Switch between list and grid view for better browsing';
@override
String get tutorialExtensionsTitle => 'Extensions';
@override
String get tutorialExtensionsDesc =>
'Extend the app\'s capabilities with community extensions.';
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
'Add new download providers or search sources';
@override
String get tutorialExtensionsTip3 =>
'Get lyrics, enhanced metadata, and more features';
@override
String get tutorialSettingsTitle => 'Customize Your Experience';
@override
String get tutorialSettingsDesc =>
'Personalize the app in Settings to match your preferences.';
@override
String get tutorialSettingsTip1 =>
'Change download location and folder organization';
@override
String get tutorialSettingsTip2 =>
'Set default audio quality and format preferences';
@override
String get tutorialSettingsTip3 => 'Customize app theme and appearance';
@override
String get tutorialReadyMessage =>
'You\'re all set! Start downloading your favorite music now.';
@override
String get tutorialExample => 'EXAMPLE';
@override
String get libraryForceFullScan => 'Force Full Scan';
@override
String get libraryForceFullScanSubtitle => 'Rescan all files, ignoring cache';
@override
String get cleanupOrphanedDownloads => 'Cleanup Orphaned Downloads';
@override
String get cleanupOrphanedDownloadsSubtitle =>
'Remove history entries for files that no longer exist';
@override
String cleanupOrphanedDownloadsResult(int count) {
return 'Removed $count orphaned entries from history';
}
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get 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`).
+335
View File
@@ -241,6 +241,8 @@
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
"@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"},
"extensionsTitle": "Extensions",
"@extensionsTitle": {"description": "Extensions page title"},
@@ -346,6 +348,10 @@
"@aboutDabMusic": {"description": "Name of Qobuz API service - DO NOT TRANSLATE"},
"aboutDabMusicDesc": "The best Qobuz streaming API. Hi-Res downloads wouldn't be possible without this!",
"@aboutDabMusicDesc": {"description": "Credit for DAB Music API"},
"aboutSpotiSaver": "SpotiSaver",
"@aboutSpotiSaver": {"description": "Name of SpotiSaver API service - DO NOT TRANSLATE"},
"aboutSpotiSaverDesc": "Tidal Hi-Res FLAC streaming endpoints. A key piece of the lossless puzzle!",
"@aboutSpotiSaverDesc": {"description": "Credit for SpotiSaver API"},
"aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.",
"@aboutAppDescription": {"description": "App description in header card"},
@@ -1408,6 +1414,8 @@
"@lossyFormatOpusSubtitle": {"description": "Opus format description"},
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
"downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
@@ -1417,6 +1425,12 @@
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
"downloadAlbumFolderStructure": "Album Folder Structure",
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
"downloadSaveFormat": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service",
@@ -1590,6 +1604,12 @@
"@recentTypeSong": {"description": "Recent access item type - song/track"},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {"description": "Recent access item type - playlist"},
"recentEmpty": "No recent items yet",
"@recentEmpty": {"description": "Empty state text for recent access list"},
"recentShowAllDownloads": "Show All Downloads",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
@@ -1700,6 +1720,10 @@
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
"settingsCache": "Storage & Cache",
"@settingsCache": {"description": "Settings menu item - cache management"},
"settingsCacheSubtitle": "View size and clear cached data",
"@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"},
"libraryTitle": "Local Library",
"@libraryTitle": {"description": "Library settings page title"},
"libraryStatus": "Library Status",
@@ -1824,6 +1848,12 @@
"@libraryFilterDateMonth": {"description": "Filter option - this month"},
"libraryFilterDateYear": "This Year",
"@libraryFilterDateYear": {"description": "Filter option - this year"},
"libraryFilterSort": "Sort",
"@libraryFilterSort": {"description": "Filter section - sort order"},
"libraryFilterSortLatest": "Latest",
"@libraryFilterSortLatest": {"description": "Sort option - newest first"},
"libraryFilterSortOldest": "Oldest",
"@libraryFilterSortOldest": {"description": "Sort option - oldest first"},
"libraryFilterActive": "{count} filter(s) active",
"@libraryFilterActive": {
"description": "Badge showing number of active filters",
@@ -1847,5 +1877,310 @@
"placeholders": {
"count": {"type": "int"}
}
},
"storageSwitchTitle": "Switch Storage Mode",
"@storageSwitchTitle": {"description": "Dialog title when switching storage mode"},
"storageSwitchToSafTitle": "Switch to SAF Storage?",
"@storageSwitchToSafTitle": {"description": "Dialog title when switching to SAF"},
"storageSwitchToAppTitle": "Switch to App Storage?",
"@storageSwitchToAppTitle": {"description": "Dialog title when switching to app storage"},
"storageSwitchToSafMessage": "Your existing downloads will remain in the current location and stay accessible.\n\nNew downloads will be saved to your selected SAF folder.",
"@storageSwitchToSafMessage": {"description": "Explanation when switching to SAF"},
"storageSwitchToAppMessage": "Your existing downloads will remain in the current SAF location and stay accessible.\n\nNew downloads will be saved to Music/SpotiFLAC folder.",
"@storageSwitchToAppMessage": {"description": "Explanation when switching to app storage"},
"storageSwitchExistingDownloads": "Existing Downloads",
"@storageSwitchExistingDownloads": {"description": "Section header for existing downloads info"},
"storageSwitchExistingDownloadsInfo": "{count} tracks in {mode} storage",
"@storageSwitchExistingDownloadsInfo": {
"description": "Info about existing downloads count",
"placeholders": {
"count": {"type": "int"},
"mode": {"type": "String"}
}
},
"storageSwitchNewDownloads": "New Downloads",
"@storageSwitchNewDownloads": {"description": "Section header for new downloads info"},
"storageSwitchNewDownloadsLocation": "Will be saved to: {location}",
"@storageSwitchNewDownloadsLocation": {
"description": "Shows where new downloads will go",
"placeholders": {
"location": {"type": "String"}
}
},
"storageSwitchContinue": "Continue",
"@storageSwitchContinue": {"description": "Button to proceed with storage switch"},
"storageSwitchSelectFolder": "Select SAF Folder",
"@storageSwitchSelectFolder": {"description": "Button to select SAF folder"},
"storageAppStorage": "App Storage",
"@storageAppStorage": {"description": "Label for app storage mode"},
"storageSafStorage": "SAF Storage",
"@storageSafStorage": {"description": "Label for SAF storage mode"},
"storageModeBadge": "Storage: {mode}",
"@storageModeBadge": {
"description": "Badge showing storage mode for a track",
"placeholders": {
"mode": {"type": "String"}
}
},
"storageStatsTitle": "Storage Statistics",
"@storageStatsTitle": {"description": "Section title for storage stats"},
"storageStatsAppCount": "{count} tracks in App Storage",
"@storageStatsAppCount": {
"description": "Count of tracks in app storage",
"placeholders": {
"count": {"type": "int"}
}
},
"storageStatsSafCount": "{count} tracks in SAF Storage",
"@storageStatsSafCount": {
"description": "Count of tracks in SAF storage",
"placeholders": {
"count": {"type": "int"}
}
},
"storageModeInfo": "Your files are stored in multiple locations",
"@storageModeInfo": {"description": "Info when user has files in both storage modes"},
"tutorialWelcomeTitle": "Welcome to SpotiFLAC!",
"@tutorialWelcomeTitle": {"description": "Tutorial welcome page title"},
"tutorialWelcomeDesc": "Let's learn how to download your favorite music in lossless quality. This quick tutorial will show you the basics.",
"@tutorialWelcomeDesc": {"description": "Tutorial welcome page description"},
"tutorialWelcomeTip1": "Download music from Spotify, Deezer, or paste any supported URL",
"@tutorialWelcomeTip1": {"description": "Tutorial welcome tip 1"},
"tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music",
"@tutorialWelcomeTip2": {"description": "Tutorial welcome tip 2"},
"tutorialWelcomeTip3": "Automatic metadata, cover art, and lyrics embedding",
"@tutorialWelcomeTip3": {"description": "Tutorial welcome tip 3"},
"tutorialSearchTitle": "Finding Music",
"@tutorialSearchTitle": {"description": "Tutorial search page title"},
"tutorialSearchDesc": "There are two easy ways to find music you want to download.",
"@tutorialSearchDesc": {"description": "Tutorial search page description"},
"tutorialSearchTip1": "Paste a Spotify or Deezer URL directly in the search box",
"@tutorialSearchTip1": {"description": "Tutorial search tip 1"},
"tutorialSearchTip2": "Or type the song name, artist, or album to search",
"@tutorialSearchTip2": {"description": "Tutorial search tip 2"},
"tutorialSearchTip3": "Supports tracks, albums, playlists, and artist pages",
"@tutorialSearchTip3": {"description": "Tutorial search tip 3"},
"tutorialDownloadTitle": "Downloading Music",
"@tutorialDownloadTitle": {"description": "Tutorial download page title"},
"tutorialDownloadDesc": "Downloading music is simple and fast. Here's how it works.",
"@tutorialDownloadDesc": {"description": "Tutorial download page description"},
"tutorialDownloadTip1": "Tap the download button next to any track to start downloading",
"@tutorialDownloadTip1": {"description": "Tutorial download tip 1"},
"tutorialDownloadTip2": "Choose your preferred quality (FLAC, Hi-Res, or MP3)",
"@tutorialDownloadTip2": {"description": "Tutorial download tip 2"},
"tutorialDownloadTip3": "Download entire albums or playlists with one tap",
"@tutorialDownloadTip3": {"description": "Tutorial download tip 3"},
"tutorialLibraryTitle": "Your Library",
"@tutorialLibraryTitle": {"description": "Tutorial library page title"},
"tutorialLibraryDesc": "All your downloaded music is organized in the Library tab.",
"@tutorialLibraryDesc": {"description": "Tutorial library page description"},
"tutorialLibraryTip1": "View download progress and queue in the Library tab",
"@tutorialLibraryTip1": {"description": "Tutorial library tip 1"},
"tutorialLibraryTip2": "Tap any track to play it with your music player",
"@tutorialLibraryTip2": {"description": "Tutorial library tip 2"},
"tutorialLibraryTip3": "Switch between list and grid view for better browsing",
"@tutorialLibraryTip3": {"description": "Tutorial library tip 3"},
"tutorialExtensionsTitle": "Extensions",
"@tutorialExtensionsTitle": {"description": "Tutorial extensions page title"},
"tutorialExtensionsDesc": "Extend the app's capabilities with community extensions.",
"@tutorialExtensionsDesc": {"description": "Tutorial extensions page description"},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"@tutorialExtensionsTip1": {"description": "Tutorial extensions tip 1"},
"tutorialExtensionsTip2": "Add new download providers or search sources",
"@tutorialExtensionsTip2": {"description": "Tutorial extensions tip 2"},
"tutorialExtensionsTip3": "Get lyrics, enhanced metadata, and more features",
"@tutorialExtensionsTip3": {"description": "Tutorial extensions tip 3"},
"tutorialSettingsTitle": "Customize Your Experience",
"@tutorialSettingsTitle": {"description": "Tutorial settings page title"},
"tutorialSettingsDesc": "Personalize the app in Settings to match your preferences.",
"@tutorialSettingsDesc": {"description": "Tutorial settings page description"},
"tutorialSettingsTip1": "Change download location and folder organization",
"@tutorialSettingsTip1": {"description": "Tutorial settings tip 1"},
"tutorialSettingsTip2": "Set default audio quality and format preferences",
"@tutorialSettingsTip2": {"description": "Tutorial settings tip 2"},
"tutorialSettingsTip3": "Customize app theme and appearance",
"@tutorialSettingsTip3": {"description": "Tutorial settings tip 3"},
"tutorialReadyMessage": "You're all set! Start downloading your favorite music now.",
"@tutorialReadyMessage": {"description": "Tutorial completion message"},
"tutorialExample": "EXAMPLE",
"@tutorialExample": {"description": "Example label in tutorial"},
"libraryForceFullScan": "Force Full Scan",
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
"libraryForceFullScanSubtitle": "Rescan all files, ignoring cache",
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
"cleanupOrphanedDownloads": "Cleanup Orphaned Downloads",
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
"cleanupOrphanedDownloadsSubtitle": "Remove history entries for files that no longer exist",
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
"cleanupOrphanedDownloadsResult": "Removed {count} orphaned entries from history",
"@cleanupOrphanedDownloadsResult": {
"description": "Snackbar after orphan cleanup",
"placeholders": {
"count": {"type": "int"}
}
},
"cleanupOrphanedDownloadsNone": "No orphaned entries 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"}
}
}
}
+3206 -2868
View File
File diff suppressed because it is too large Load Diff
+22 -18
View File
@@ -11,21 +11,9 @@ import 'package:spotiflac_android/services/cover_cache_manager.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await CoverCacheManager.initialize();
debugPrint('CoverCacheManager initialized: ${CoverCacheManager.isInitialized}');
await Future.wait([
NotificationService().initialize(),
ShareIntentService().initialize(),
]);
runApp(
ProviderScope(
child: const _EagerInitialization(
child: SpotiFLACApp(),
),
),
ProviderScope(child: const _EagerInitialization(child: SpotiFLACApp())),
);
}
@@ -35,27 +23,43 @@ class _EagerInitialization extends ConsumerStatefulWidget {
final Widget child;
@override
ConsumerState<_EagerInitialization> createState() => _EagerInitializationState();
ConsumerState<_EagerInitialization> createState() =>
_EagerInitializationState();
}
class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
@override
void initState() {
super.initState();
_initializeAppServices();
_initializeExtensions();
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 {
try {
final appDir = await getApplicationDocumentsDirectory();
final extensionsDir = '${appDir.path}/extensions';
final dataDir = '${appDir.path}/extension_data';
await Directory(extensionsDir).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) {
debugPrint('Failed to initialize extensions: $e');
}
+4
View File
@@ -28,6 +28,7 @@ class DownloadItem {
final DownloadStatus status;
final double progress;
final double speedMBps;
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
final String? filePath;
final String? error;
final DownloadErrorType? errorType;
@@ -41,6 +42,7 @@ class DownloadItem {
this.status = DownloadStatus.queued,
this.progress = 0.0,
this.speedMBps = 0.0,
this.bytesReceived = 0,
this.filePath,
this.error,
this.errorType,
@@ -55,6 +57,7 @@ class DownloadItem {
DownloadStatus? status,
double? progress,
double? speedMBps,
int? bytesReceived,
String? filePath,
String? error,
DownloadErrorType? errorType,
@@ -68,6 +71,7 @@ class DownloadItem {
status: status ?? this.status,
progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived,
filePath: filePath ?? this.filePath,
error: error ?? this.error,
errorType: errorType ?? this.errorType,
+2
View File
@@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?,
error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -30,6 +31,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress,
'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived,
'filePath': instance.filePath,
'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+23 -1
View File
@@ -8,6 +8,8 @@ class AppSettings {
final String audioQuality;
final String filenameFormat;
final String downloadDirectory;
final String storageMode; // 'app' or 'saf'
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
final bool embedLyrics;
final bool maxQualityCover;
@@ -17,6 +19,7 @@ class AppSettings {
final String updateChannel;
final bool hasSearchedBefore;
final String folderOrganization;
final bool useAlbumArtistForFolders;
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
@@ -32,7 +35,7 @@ class AppSettings {
final bool showExtensionStore;
final String locale;
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
final String downloadNetworkMode; // 'any' = WiFi + Mobile, 'wifi_only' = WiFi only
@@ -41,12 +44,17 @@ class AppSettings {
final bool localLibraryEnabled; // Enable local library scanning
final String localLibraryPath; // Path to scan for audio files
final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks
// Tutorial/Onboarding
final bool hasCompletedTutorial; // Track if user has completed the app tutorial
const AppSettings({
this.defaultService = 'tidal',
this.audioQuality = 'LOSSLESS',
this.filenameFormat = '{title} - {artist}',
this.downloadDirectory = '',
this.storageMode = 'app',
this.downloadTreeUri = '',
this.autoFallback = true,
this.embedLyrics = true,
this.maxQualityCover = true,
@@ -56,6 +64,7 @@ class AppSettings {
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
@@ -79,6 +88,8 @@ class AppSettings {
this.localLibraryEnabled = false,
this.localLibraryPath = '',
this.localLibraryShowDuplicates = true,
// Tutorial default
this.hasCompletedTutorial = false,
});
AppSettings copyWith({
@@ -86,6 +97,8 @@ class AppSettings {
String? audioQuality,
String? filenameFormat,
String? downloadDirectory,
String? storageMode,
String? downloadTreeUri,
bool? autoFallback,
bool? embedLyrics,
bool? maxQualityCover,
@@ -95,6 +108,7 @@ class AppSettings {
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
bool? useAlbumArtistForFolders,
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
@@ -119,12 +133,16 @@ class AppSettings {
bool? localLibraryEnabled,
String? localLibraryPath,
bool? localLibraryShowDuplicates,
// Tutorial
bool? hasCompletedTutorial,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
audioQuality: audioQuality ?? this.audioQuality,
filenameFormat: filenameFormat ?? this.filenameFormat,
downloadDirectory: downloadDirectory ?? this.downloadDirectory,
storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback,
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
@@ -134,6 +152,8 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
@@ -157,6 +177,8 @@ class AppSettings {
localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled,
localLibraryPath: localLibraryPath ?? this.localLibraryPath,
localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates,
// Tutorial
hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial,
);
}
+8
View File
@@ -11,6 +11,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
audioQuality: json['audioQuality'] as String? ?? 'LOSSLESS',
filenameFormat: json['filenameFormat'] as String? ?? '{title} - {artist}',
downloadDirectory: json['downloadDirectory'] as String? ?? '',
storageMode: json['storageMode'] as String? ?? 'app',
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
@@ -20,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -46,6 +49,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
localLibraryPath: json['localLibraryPath'] as String? ?? '',
localLibraryShowDuplicates:
json['localLibraryShowDuplicates'] as bool? ?? true,
hasCompletedTutorial: json['hasCompletedTutorial'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -54,6 +58,8 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'audioQuality': instance.audioQuality,
'filenameFormat': instance.filenameFormat,
'downloadDirectory': instance.downloadDirectory,
'storageMode': instance.storageMode,
'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback,
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
@@ -63,6 +69,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
@@ -85,4 +92,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'localLibraryEnabled': instance.localLibraryEnabled,
'localLibraryPath': instance.localLibraryPath,
'localLibraryShowDuplicates': instance.localLibraryShowDuplicates,
'hasCompletedTutorial': instance.hasCompletedTutorial,
};
File diff suppressed because it is too large Load Diff
+80 -2
View File
@@ -1,4 +1,6 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -47,6 +49,20 @@ class ExploreItem {
durationMs: json['duration_ms'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'uri': uri,
'type': type,
'name': name,
'artists': artists,
'description': description,
'cover_url': coverUrl,
'provider_id': providerId,
'album_id': albumId,
'album_name': albumName,
'duration_ms': durationMs,
};
}
class ExploreSection {
@@ -75,6 +91,12 @@ class ExploreSection {
isYTMusicQuickPicks: isQuickPicks,
);
}
Map<String, dynamic> toJson() => {
'uri': uri,
'title': title,
'items': items.map((i) => i.toJson()).toList(),
};
}
class ExploreState {
@@ -136,20 +158,71 @@ bool _isYTMusicQuickPicksItems(List<ExploreItem> items) {
}
class ExploreNotifier extends Notifier<ExploreState> {
static const _cacheKey = 'explore_home_feed_cache';
static const _cacheTsKey = 'explore_home_feed_ts';
@override
ExploreState build() {
_restoreFromCache();
return const ExploreState();
}
/// Restore cached home feed from SharedPreferences immediately on startup
Future<void> _restoreFromCache() async {
try {
final prefs = await SharedPreferences.getInstance();
final cached = prefs.getString(_cacheKey);
final cachedTs = prefs.getInt(_cacheTsKey);
if (cached == null || cached.isEmpty) return;
final data = jsonDecode(cached) as Map<String, dynamic>;
final sectionsData = data['sections'] as List<dynamic>? ?? [];
final sections = sectionsData
.map((s) => ExploreSection.fromJson(s as Map<String, dynamic>))
.toList();
if (sections.isEmpty) return;
final lastFetched = cachedTs != null
? DateTime.fromMillisecondsSinceEpoch(cachedTs)
: null;
_log.i('Restored ${sections.length} cached explore sections');
state = ExploreState(
greeting: _getLocalGreeting(),
sections: sections,
lastFetched: lastFetched,
);
} catch (e) {
_log.w('Failed to restore explore cache: $e');
}
}
/// Save home feed to SharedPreferences for instant restore on next launch
Future<void> _saveToCache(List<ExploreSection> sections) async {
try {
final prefs = await SharedPreferences.getInstance();
final data = {
'sections': sections.map((s) => s.toJson()).toList(),
};
await prefs.setString(_cacheKey, jsonEncode(data));
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${sections.length} explore sections to cache');
} catch (e) {
_log.w('Failed to save explore cache: $e');
}
}
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed');
_log.d('Using cached home feed (fresh enough)');
return;
}
@@ -158,7 +231,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
return;
}
state = state.copyWith(isLoading: true, error: null);
// Only show loading spinner if we have no cached content to display
final showLoading = !state.hasContent;
state = state.copyWith(isLoading: showLoading, error: null);
try {
final extState = ref.read(extensionProvider);
@@ -231,6 +306,9 @@ class ExploreNotifier extends Notifier<ExploreState> {
sections: sections,
lastFetched: DateTime.now(),
);
// Save to disk cache for instant restore on next app launch
_saveToCache(sections);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(
+51 -4
View File
@@ -1,10 +1,15 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExtensionProvider');
const _metadataProviderPriorityKey = 'metadata_provider_priority';
const _providerPriorityKey = 'provider_priority';
class Extension {
final String id;
final String name;
@@ -622,7 +627,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadProviderPriority() async {
try {
final priority = await PlatformBridge.getProviderPriority();
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_providerPriorityKey);
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
_log.d('Loaded provider priority from prefs: $priority');
// Sync to Go backend
await PlatformBridge.setProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getProviderPriority();
_log.d('Using default provider priority: $priority');
}
state = state.copyWith(providerPriority: priority);
} catch (e) {
_log.e('Failed to load provider priority: $e');
@@ -632,9 +653,14 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setProviderPriority(List<String> priority) async {
try {
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
// Sync to Go backend
await PlatformBridge.setProviderPriority(priority);
state = state.copyWith(providerPriority: priority);
_log.d('Updated provider priority: $priority');
_log.d('Saved provider priority: $priority');
} catch (e) {
_log.e('Failed to set provider priority: $e');
state = state.copyWith(error: e.toString());
@@ -643,7 +669,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> loadMetadataProviderPriority() async {
try {
final priority = await PlatformBridge.getMetadataProviderPriority();
// Load from SharedPreferences first (persisted)
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_metadataProviderPriorityKey);
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
_log.d('Loaded metadata provider priority from prefs: $priority');
// Sync to Go backend
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
// Fallback to Go backend default
priority = await PlatformBridge.getMetadataProviderPriority();
_log.d('Using default metadata provider priority: $priority');
}
state = state.copyWith(metadataProviderPriority: priority);
} catch (e) {
_log.e('Failed to load metadata provider priority: $e');
@@ -652,9 +694,14 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
// Save to SharedPreferences for persistence
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
// Sync to Go backend
await PlatformBridge.setMetadataProviderPriority(priority);
state = state.copyWith(metadataProviderPriority: priority);
_log.d('Updated metadata provider priority: $priority');
_log.d('Saved metadata provider priority: $priority');
} catch (e) {
_log.e('Failed to set metadata provider priority: $e');
state = state.copyWith(error: e.toString());
+335 -55
View File
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
@@ -9,6 +11,7 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at';
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
class LocalLibraryState {
final List<LocalLibraryItem> items;
@@ -16,8 +19,11 @@ class LocalLibraryState {
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final int scannedFiles;
final int scanErrorCount;
final bool scanWasCancelled;
final DateTime? lastScannedAt;
final int excludedDownloadedCount;
final Set<String> _isrcSet;
final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc;
@@ -28,18 +34,21 @@ class LocalLibraryState {
this.scanProgress = 0,
this.scanCurrentFile,
this.scanTotalFiles = 0,
this.scannedFiles = 0,
this.scanErrorCount = 0,
this.scanWasCancelled = false,
this.lastScannedAt,
}) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!)
.toSet(),
_trackKeySet = items.map((item) => item.matchKey).toSet(),
_byIsrc = Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
);
this.excludedDownloadedCount = 0,
}) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!)
.toSet(),
_trackKeySet = items.map((item) => item.matchKey).toSet(),
_byIsrc = Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
);
bool hasIsrc(String isrc) => _isrcSet.contains(isrc);
@@ -71,8 +80,11 @@ class LocalLibraryState {
double? scanProgress,
String? scanCurrentFile,
int? scanTotalFiles,
int? scannedFiles,
int? scanErrorCount,
bool? scanWasCancelled,
DateTime? lastScannedAt,
int? excludedDownloadedCount,
}) {
return LocalLibraryState(
items: items ?? this.items,
@@ -80,16 +92,24 @@ class LocalLibraryState {
scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
scannedFiles: scannedFiles ?? this.scannedFiles,
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
excludedDownloadedCount:
excludedDownloadedCount ?? this.excludedDownloadedCount,
);
}
}
class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
static const _progressPollingInterval = Duration(milliseconds: 800);
Timer? _progressTimer;
bool _isLoaded = false;
bool _scanCancelRequested = false;
int _progressPollingErrorCount = 0;
@override
LocalLibraryState build() {
@@ -109,23 +129,31 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final jsonList = await _db.getAll();
final items = jsonList
.map((e) => LocalLibraryItem.fromJson(e))
.toList();
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
DateTime? lastScannedAt;
var excludedDownloadedCount = 0;
try {
final prefs = await SharedPreferences.getInstance();
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
}
excludedDownloadedCount =
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
} catch (e) {
_log.w('Failed to load lastScannedAt: $e');
}
state = state.copyWith(items: items, lastScannedAt: lastScannedAt);
_log.i('Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt');
state = state.copyWith(
items: items,
lastScannedAt: lastScannedAt,
excludedDownloadedCount: excludedDownloadedCount,
);
_log.i(
'Loaded ${items.length} items from library database, lastScannedAt: '
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
);
} catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack);
}
@@ -136,24 +164,32 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
await _loadFromDatabase();
}
Future<void> startScan(String folderPath) async {
Future<void> startScan(
String folderPath, {
bool forceFullScan = false,
}) async {
if (state.isScanning) {
_log.w('Scan already in progress');
return;
}
_log.i('Starting library scan: $folderPath');
_scanCancelRequested = false;
_log.i(
'Starting library scan: $folderPath (incremental: ${!forceFullScan})',
);
state = state.copyWith(
isScanning: true,
scanProgress: 0,
scanCurrentFile: null,
scanTotalFiles: 0,
scannedFiles: 0,
scanErrorCount: 0,
scanWasCancelled: false,
);
try {
final cacheDir = await getApplicationCacheDirectory();
final coverCacheDir = '${cacheDir.path}/library_covers';
final appSupportDir = await getApplicationSupportDirectory();
final coverCacheDir = '${appSupportDir.path}/library_covers';
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
_log.i('Cover cache directory set to: $coverCacheDir');
} catch (e) {
@@ -163,36 +199,194 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_startProgressPolling();
try {
final results = await PlatformBridge.scanLibraryFolder(folderPath);
final items = <LocalLibraryItem>[];
for (final json in results) {
final item = LocalLibraryItem.fromJson(json);
items.add(item);
}
final isSaf = folderPath.startsWith('content://');
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
}
state = state.copyWith(
items: items,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
// Get all file paths from download history to exclude them
final downloadedPaths = await _historyDb.getAllFilePaths();
_log.i(
'Excluding ${downloadedPaths.length} downloaded files from library scan',
);
_log.i('Scan complete: ${items.length} tracks found');
if (forceFullScan) {
// Full scan path - ignores existing data
final results = isSaf
? await PlatformBridge.scanSafTree(folderPath)
: await PlatformBridge.scanLibraryFolder(folderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
return;
}
final items = <LocalLibraryItem>[];
int skippedDownloads = 0;
for (final json in results) {
final filePath = json['filePath'] as String?;
// Skip files that are already in download history
if (filePath != null && downloadedPaths.contains(filePath)) {
skippedDownloads++;
continue;
}
final item = LocalLibraryItem.fromJson(json);
items.add(item);
}
if (skippedDownloads > 0) {
_log.i('Skipped $skippedDownloads files already in download history');
}
await _db.upsertBatch(items.map((e) => e.toJson()).toList());
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
}
state = state.copyWith(
items: items,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
);
_log.i(
'Full scan complete: ${items.length} tracks found, '
'$skippedDownloads already in downloads',
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
_log.i(
'Incremental scan: ${existingFiles.length} existing files in database',
);
final backfilledModTimes = await _backfillLegacyFileModTimes(
isSaf: isSaf,
existingFiles: existingFiles,
);
if (backfilledModTimes.isNotEmpty) {
await _db.updateFileModTimes(backfilledModTimes);
existingFiles.addAll(backfilledModTimes);
_log.i('Backfilled ${backfilledModTimes.length} legacy mod times');
}
// Use appropriate incremental scan method based on SAF or not
final Map<String, dynamic> result;
if (isSaf) {
result = await PlatformBridge.scanSafTreeIncremental(
folderPath,
existingFiles,
);
} else {
result = await PlatformBridge.scanLibraryFolderIncremental(
folderPath,
existingFiles,
);
}
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
return;
}
// Parse incremental scan result
// SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths'
final scannedList =
(result['files'] as List<dynamic>?) ??
(result['scanned'] as List<dynamic>?) ??
[];
final deletedPaths =
(result['removedUris'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
(result['deletedPaths'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
final skippedCount = result['skippedCount'] as int? ?? 0;
final totalFiles = result['totalFiles'] as int? ?? 0;
_log.i(
'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)
final updatedItems = <LocalLibraryItem>[];
int skippedDownloads = 0;
if (scannedList.isNotEmpty) {
for (final json in scannedList) {
final map = json as Map<String, dynamic>;
final filePath = map['filePath'] as String?;
if (filePath != null && downloadedPaths.contains(filePath)) {
skippedDownloads++;
continue;
}
final item = LocalLibraryItem.fromJson(map);
updatedItems.add(item);
currentByPath[item.filePath] = item;
}
if (updatedItems.isNotEmpty) {
await _db.upsertBatch(updatedItems.map((e) => e.toJson()).toList());
_log.i('Upserted ${updatedItems.length} items');
}
if (skippedDownloads > 0) {
_log.i(
'Skipped $skippedDownloads files already in download history',
);
}
}
// Delete removed items
if (deletedPaths.isNotEmpty) {
final deleteCount = await _db.deleteByPaths(deletedPaths);
for (final path in deletedPaths) {
currentByPath.remove(path);
}
_log.i('Deleted $deleteCount items from database');
}
final items = currentByPath.values.toList(growable: false)
..sort(_compareLibraryItems);
final now = DateTime.now();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
}
state = state.copyWith(
items: items,
isScanning: false,
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
);
_log.i(
'Incremental scan complete: ${items.length} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
);
}
} catch (e, stack) {
_log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false);
state = state.copyWith(isScanning: false, scanWasCancelled: false);
} finally {
_stopProgressPolling();
}
@@ -200,35 +394,44 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
void _startProgressPolling() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
_progressTimer = Timer.periodic(_progressPollingInterval, (_) async {
try {
final progress = await PlatformBridge.getLibraryScanProgress();
state = state.copyWith(
scanProgress: (progress['progress_pct'] as num?)?.toDouble() ?? 0,
scanCurrentFile: progress['current_file'] as String?,
scanTotalFiles: progress['total_files'] as int? ?? 0,
scannedFiles: progress['scanned_files'] as int? ?? 0,
scanErrorCount: progress['error_count'] as int? ?? 0,
);
if (progress['is_complete'] == true) {
_stopProgressPolling();
}
} catch (_) {}
_progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Library scan progress polling failed: $e');
}
}
});
}
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressTimer = null;
_progressPollingErrorCount = 0;
}
Future<void> cancelScan() async {
if (!state.isScanning) return;
_log.i('Cancelling library scan');
_scanCancelRequested = true;
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false);
state = state.copyWith(isScanning: false, scanWasCancelled: true);
_stopProgressPolling();
}
@@ -242,14 +445,15 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<void> clearLibrary() async {
await _db.clearAll();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey);
await prefs.remove(_excludedDownloadedCountKey);
} catch (e) {
_log.w('Failed to clear lastScannedAt: $e');
}
state = LocalLibraryState();
_log.i('Library cleared');
}
@@ -273,7 +477,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
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) {
final byIsrc = state.getByIsrc(isrc);
if (byIsrc != null) return byIsrc;
@@ -286,7 +494,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<List<LocalLibraryItem>> search(String query) async {
if (query.isEmpty) return [];
final results = await _db.search(query);
return results.map((e) => LocalLibraryItem.fromJson(e)).toList();
}
@@ -294,6 +502,78 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
Future<int> getCount() async {
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({
required bool isSaf,
required Map<String, int> existingFiles,
}) async {
final legacyPaths = existingFiles.entries
.where((entry) => entry.value <= 0)
.map((entry) => entry.key)
.toList();
if (legacyPaths.isEmpty) {
return const {};
}
if (isSaf) {
final uris = legacyPaths
.where((path) => path.startsWith('content://'))
.toList();
if (uris.isEmpty) {
return const {};
}
const chunkSize = 500;
final backfilled = <String, int>{};
try {
for (var i = 0; i < uris.length; i += chunkSize) {
if (_scanCancelRequested) {
break;
}
final end = (i + chunkSize < uris.length)
? i + chunkSize
: uris.length;
final chunk = uris.sublist(i, end);
final chunkResult = await PlatformBridge.getSafFileModTimes(chunk);
backfilled.addAll(chunkResult);
}
return backfilled;
} catch (e) {
_log.w('Failed to backfill SAF mod times: $e');
return const {};
}
}
final backfilled = <String, int>{};
for (final path in legacyPaths) {
if (_scanCancelRequested || path.startsWith('content://')) {
continue;
}
try {
final stat = await File(path).stat();
if (stat.type == FileSystemEntityType.file) {
backfilled[path] = stat.modified.millisecondsSinceEpoch;
}
} catch (_) {}
}
return backfilled;
}
}
final localLibraryProvider =
+81 -20
View File
@@ -8,12 +8,16 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 1;
const _currentMigrationVersion = 2;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
class SettingsNotifier extends Notifier<AppSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
bool _isSavingSettings = false;
bool _saveQueued = false;
String? _pendingSettingsJson;
@override
AppSettings build() {
@@ -26,47 +30,78 @@ class SettingsNotifier extends Notifier<AppSettings> {
final json = prefs.getString(_settingsKey);
if (json != null) {
state = AppSettings.fromJson(jsonDecode(json));
await _runMigrations(prefs);
}
await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials();
LogBuffer.loggingEnabled = state.enableLogging;
}
Future<void> _runMigrations(SharedPreferences prefs) async {
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
if (lastMigration < 1) {
if (!state.useCustomSpotifyCredentials) {
state = state.copyWith(metadataSource: 'deezer');
await _saveSettings();
}
}
if (lastMigration < _currentMigrationVersion) {
if (state.downloadTreeUri.isNotEmpty && state.storageMode != 'saf') {
state = state.copyWith(storageMode: 'saf');
}
// Migration 2: existing users who already completed setup should skip tutorial
if (!state.isFirstLaunch && !state.hasCompletedTutorial) {
state = state.copyWith(hasCompletedTutorial: true);
}
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
}
}
Future<void> _saveSettings() async {
final prefs = await _prefs;
final settingsToSave = state.copyWith(
spotifyClientSecret: '',
);
await prefs.setString(_settingsKey, jsonEncode(settingsToSave.toJson()));
final settingsToSave = state.copyWith(spotifyClientSecret: '');
_pendingSettingsJson = jsonEncode(settingsToSave.toJson());
if (_isSavingSettings) {
_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 {
final storedSecret = await _secureStorage.read(key: _spotifyClientSecretKey);
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) {
await _secureStorage.write(key: _spotifyClientSecretKey, value: prefsSecret);
await _secureStorage.write(
key: _spotifyClientSecretKey,
value: prefsSecret,
);
}
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
@@ -91,7 +126,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty &&
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId,
@@ -120,6 +155,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setStorageMode(String mode) {
final normalized = mode == 'saf' ? 'saf' : 'app';
state = state.copyWith(storageMode: normalized);
_saveSettings();
}
void setDownloadTreeUri(String uri, {String? displayName}) {
final nextDisplay = displayName ?? state.downloadDirectory;
state = state.copyWith(
downloadTreeUri: uri,
storageMode: uri.isNotEmpty ? 'saf' : state.storageMode,
downloadDirectory: nextDisplay,
);
_saveSettings();
}
void setAutoFallback(bool enabled) {
state = state.copyWith(autoFallback: enabled);
_saveSettings();
@@ -148,7 +199,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setConcurrentDownloads(int count) {
final clamped = count.clamp(1, 3);
final clamped = count.clamp(1, 5);
state = state.copyWith(concurrentDownloads: clamped);
_saveSettings();
}
@@ -175,6 +226,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();
@@ -201,7 +257,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
Future<void> setSpotifyCredentials(String clientId, String clientSecret) async {
Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
@@ -212,10 +271,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
Future<void> clearSpotifyCredentials() async {
state = state.copyWith(
spotifyClientId: '',
spotifyClientSecret: '',
);
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
await _storeSpotifyClientSecret('');
_saveSettings();
_applySpotifyCredentials();
@@ -277,7 +333,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
}
@@ -306,6 +362,11 @@ void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(localLibraryShowDuplicates: show);
_saveSettings();
}
void setTutorialComplete() {
state = state.copyWith(hasCompletedTutorial: true);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+422 -163
View File
@@ -1,8 +1,8 @@
import 'dart:io';
import 'dart:ui';
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/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
@@ -12,9 +12,11 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/recent_access_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionArtistScreen;
import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionArtistScreen;
class _AlbumCache {
static final Map<String, _CacheEntry> _cache = {};
@@ -69,7 +71,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false;
String? _artistId;
final ScrollController _scrollController = ScrollController();
@@ -77,34 +78,35 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
// Use extensionId if available, otherwise detect from albumId prefix
final providerId = widget.extensionId ??
final providerId =
widget.extensionId ??
(widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify');
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
ref
.read(recentAccessProvider.notifier)
.recordAlbumAccess(
id: widget.albumId,
name: widget.albumName,
artistName: widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl,
providerId: providerId,
);
});
if (widget.tracks != null && widget.tracks!.isNotEmpty) {
_tracks = widget.tracks;
} else {
_tracks = _AlbumCache.get(widget.albumId);
}
_artistId = widget.artistId;
if (_tracks == null || _tracks!.isEmpty) {
_fetchTracks();
}
_extractDominantColor();
}
@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) {
if (date.length >= 10) {
final parts = date.substring(0, 10).split('-');
@@ -144,27 +138,32 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return date;
}
Future<void> _fetchTracks() async {
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
metadata = await PlatformBridge.getDeezerMetadata(
'album',
deezerAlbumId,
);
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
}
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = albumInfo?['artist_id'] as String?;
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
@@ -210,15 +209,19 @@ Future<void> _fetchTracks() async {
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
if (_isLoading)
const SliverToBoxAdapter(child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
)),
if (_error != null)
SliverToBoxAdapter(child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
)),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
),
if (_error != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: _buildErrorWidget(_error!, colorScheme),
),
),
if (!_isLoading && _error == null && tracks.isNotEmpty) ...[
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, colorScheme, tracks),
@@ -230,12 +233,17 @@ Future<void> _fetchTracks() async {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@@ -256,26 +264,52 @@ Future<void> _fetchTracks() async {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
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,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@@ -301,15 +335,19 @@ Future<void> _fetchTracks() async {
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -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(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
@@ -336,18 +377,20 @@ Future<void> _fetchTracks() async {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
final releaseDate = tracks.isNotEmpty ? tracks.first.releaseDate : null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
@@ -355,7 +398,10 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
@@ -376,27 +422,61 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
runSpacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onSecondaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
context.l10n.tracksCount(tracks.length),
style: TextStyle(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
if (releaseDate != null && releaseDate.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.calendar_today, size: 14, color: colorScheme.onTertiaryContainer),
Icon(
Icons.calendar_today,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(_formatReleaseDate(releaseDate), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
_formatReleaseDate(releaseDate),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
@@ -410,7 +490,9 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
label: Text(context.l10n.downloadAllCount(tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
@@ -430,28 +512,35 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<Track> tracks) {
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<Track> tracks,
) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: tracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _AlbumTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
}, childCount: tracks.length),
);
}
@@ -464,13 +553,23 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
}
@@ -484,27 +583,44 @@ Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
trackName: '${tracks.length} tracks',
artistName: widget.albumName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(tracks.length),
),
),
);
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length)),
),
);
}
}
void _navigateToArtist(BuildContext context, String artistName) {
final artistId = _artistId ??
final artistId =
_artistId ??
(widget.albumId.startsWith('deezer:') ? 'deezer:unknown' : 'unknown');
if (artistId == 'unknown' || artistId == 'deezer:unknown' || artistId.isEmpty) {
if (artistId == 'unknown' ||
artistId == 'deezer:unknown' ||
artistId.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Artist information not available')),
);
return;
}
if (widget.extensionId != null) {
Navigator.push(
context,
@@ -519,7 +635,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
);
return;
}
Navigator.push(
context,
MaterialPageRoute(
@@ -533,10 +649,11 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
}
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
final isRateLimit = error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
final isRateLimit =
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
if (isRateLimit) {
return Card(
elevation: 0,
@@ -575,7 +692,7 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
),
);
}
return Card(
elevation: 0,
color: colorScheme.errorContainer.withValues(alpha: 0.5),
@@ -586,7 +703,9 @@ ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.s
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))),
Expanded(
child: Text(error, style: TextStyle(color: colorScheme.error)),
),
],
),
),
@@ -603,33 +722,44 @@ class _AlbumTrackItem extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
downloadQueueLookupProvider.select(
(lookup) => lookup.byTrackId[track.id],
),
);
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}),
);
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
),
);
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -637,8 +767,10 @@ class _AlbumTrackItem extends ConsumerWidget {
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: SizedBox(
width: 32,
child: Center(
@@ -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(
children: [
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))),
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
@@ -666,51 +815,102 @@ child: ListTile(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3),
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)),
Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
],
),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
trailing: _buildDownloadButton(
context,
ref,
colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
),
),
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
void _handleTap(
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
}
return;
}
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
Widget _buildDownloadButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
@@ -721,11 +921,29 @@ child: ListTile(
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
);
} else if (isFinalizing) {
return SizedBox(
@@ -734,7 +952,11 @@ child: ListTile(
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
@@ -746,17 +968,54 @@ child: ListTile(
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
],
),
);
} else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else {
return GestureDetector(
onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
);
}
}
File diff suppressed because it is too large Load Diff
+308 -170
View File
@@ -1,13 +1,11 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.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/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -25,13 +23,13 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
});
@override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
ConsumerState<DownloadedAlbumScreen> createState() =>
_DownloadedAlbumScreenState();
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@@ -39,7 +37,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
@@ -56,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)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
) {
return allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
// Use lowercase for case-insensitive matching
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
final itemKey =
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey =
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
return itemKey == albumKey;
}).toList()
..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
}).toList()..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
}
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
@@ -174,27 +152,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final idsToDelete = _selectedIds.toList();
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
await deleteFile(item.filePath);
} catch (_) {}
historyNotifier.removeFromHistory(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
}
}
@@ -202,12 +179,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Future<void> _openFile(String filePath) async {
try {
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
await openFile(filePath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
@@ -215,12 +193,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void _navigateToMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl);
Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
));
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) =>
TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
FadeTransition(opacity: animation, child: child),
),
);
}
void _precacheCover(String? url) {
@@ -238,22 +221,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: Center(
child: Text('No tracks found for this album'),
),
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text('No tracks found for this album')),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -279,17 +260,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
@@ -298,15 +286,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -324,27 +318,52 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
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,
],
),
),
),
),
@@ -354,7 +373,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@@ -371,15 +390,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -388,14 +411,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
@@ -403,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(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
@@ -418,43 +453,70 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
Icon(
Icons.download_done,
size: 14,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
context.l10n.downloadedAlbumDownloadedCount(
tracks.length,
),
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
if (_getCommonQuality(tracks) != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
style: TextStyle(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.onTertiaryContainer
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
@@ -481,7 +543,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -489,14 +555,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(
context.l10n.downloadedAlbumTracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
@@ -504,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);
if (discMap.length <= 1) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
}, childCount: tracks.length),
);
}
@@ -543,12 +620,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
return SliverList(delegate: SliverChildListDelegate(children));
}
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
Widget _buildDiscSeparator(
BuildContext context,
ColorScheme colorScheme,
int discNumber,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
@@ -562,7 +641,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
@@ -586,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);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -609,12 +702,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
@@ -636,7 +740,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Text(
track.artistName,
@@ -644,22 +750,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
trailing: _isSelectionMode
? null
: IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
),
),
);
}
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
@@ -703,12 +818,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
context.l10n.downloadedAlbumSelectedCount(
selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
allSelected
? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
@@ -721,9 +842,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
),
],
),
@@ -731,18 +861,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
backgroundColor: selectedCount > 0
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
-413
View File
@@ -1,413 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
final _urlController = TextEditingController();
int _currentIndex = 0;
@override
void dispose() {
_urlController.dispose();
super.dispose();
}
Future<void> _pasteFromClipboard() async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null) {
_urlController.text = data!.text!;
}
}
Future<void> _fetchMetadata() async {
final url = _urlController.text.trim();
if (url.isEmpty) return;
if (url.startsWith('http') || url.startsWith('spotify:')) {
await ref.read(trackProvider.notifier).fetchFromUrl(url);
} else {
final settings = ref.read(settingsProvider);
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
}
}
void _downloadTrack(Track track) {
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addToQueue(
track,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added "${track.name}" to queue')),
);
}
void _downloadAll() {
final trackState = ref.read(trackProvider);
if (trackState.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
trackState.tracks,
settings.defaultService,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added ${trackState.tracks.length} tracks to queue')),
);
}
void _onNavTap(int index) {
setState(() => _currentIndex = index);
switch (index) {
case 0:
break;
case 1:
context.push('/queue');
break;
case 2:
context.push('/history');
break;
}
}
@override
Widget build(BuildContext context) {
final trackState = ref.watch(trackProvider);
final queuedCount =
ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
final colorScheme = Theme.of(context).colorScheme;
final tracks = trackState.tracks;
return Scaffold(
appBar: AppBar(
leading: Padding(
padding: const EdgeInsets.all(8.0),
child: CircleAvatar(
backgroundColor: colorScheme.primaryContainer,
child: Icon(Icons.music_note, color: colorScheme.onPrimaryContainer, size: 20),
),
),
title: const Text('SpotiFLAC'),
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => context.push('/settings'),
),
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: TextField(
controller: _urlController,
decoration: InputDecoration(
hintText: 'Paste Spotify URL or search...',
prefixIcon: const Icon(Icons.link),
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.paste), onPressed: _pasteFromClipboard),
IconButton(icon: const Icon(Icons.search), onPressed: _fetchMetadata),
],
),
),
onSubmitted: (_) => _fetchMetadata(),
),
),
if (trackState.error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
trackState.error!,
style: TextStyle(color: colorScheme.error),
),
),
if (trackState.isLoading)
LinearProgressIndicator(color: colorScheme.primary),
if (trackState.albumName != null || trackState.playlistName != null)
_buildHeader(trackState, colorScheme),
if (tracks.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: FilledButton.icon(
onPressed: _downloadAll,
icon: const Icon(Icons.download),
label: Text('Download All (${tracks.length})'),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
),
),
Expanded(
child: tracks.isEmpty
? _buildEmptyState(colorScheme)
: ListView.builder(
itemCount: tracks.length,
itemBuilder: (context, index) =>
_buildTrackTile(tracks[index], colorScheme),
),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _currentIndex,
onDestinationSelected: _onNavTap,
destinations: [
const NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Badge(
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music_outlined),
),
selectedIcon: Badge(
isLabelVisible: queuedCount > 0,
label: Text('$queuedCount'),
child: const Icon(Icons.queue_music),
),
label: 'Queue',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'History',
),
],
),
);
}
Widget _buildHeader(TrackState state, ColorScheme colorScheme) {
return Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (state.coverUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: state.coverUrl!,
width: 80,
height: 80,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(
width: 80,
height: 80,
color: colorScheme.surfaceContainerHighest,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.albumName ?? state.playlistName ?? '',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${state.tracks.length} tracks',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
FilledButton.tonal(
onPressed: _downloadAll,
style: FilledButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: const Icon(Icons.download),
),
],
),
),
);
}
Widget _buildTrackTile(Track track, ColorScheme colorScheme) {
final isCollection = track.isCollection;
String subtitleText;
if (isCollection) {
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
final capitalizedType = typeLabel.isNotEmpty
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
: 'Album';
final year = track.releaseDate != null && track.releaseDate!.length >= 4
? track.releaseDate!.substring(0, 4)
: '';
subtitleText = '$capitalizedType${track.artistName}${year.isNotEmpty ? '$year' : ''}';
} else {
subtitleText = track.artistName;
}
return ListTile(
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
isCollection ? Icons.album : Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
subtitleText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: isCollection
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
: Text(
_formatDuration(track.duration),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(track),
);
}
Future<void> _openCollection(Track track) async {
final extensionId = track.source;
if (extensionId == null) return;
try {
if (track.isAlbumItem) {
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
if (albumData != null && mounted) {
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
albumName: albumData['name'] as String? ?? track.name,
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
);
}
} else if (track.isPlaylistItem) {
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
if (playlistData != null && mounted) {
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
ref.read(trackProvider.notifier).setTracksFromCollection(
tracks: tracks,
playlistName: playlistData['name'] as String? ?? track.name,
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load: $e')),
);
}
}
}
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
int durationMs = 0;
final durationValue = data['duration_ms'];
if (durationValue is int) {
durationMs = durationValue;
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
artistName: (data['artists'] ?? '').toString(),
albumName: (data['album_name'] ?? '').toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
duration: (durationMs / 1000).round(),
releaseDate: data['release_date']?.toString(),
source: source,
);
}
String _formatDuration(int ms) {
if (ms == 0) return '';
final duration = Duration(milliseconds: ms);
final minutes = duration.inMinutes;
final seconds = duration.inSeconds % 60;
return '$minutes:${seconds.toString().padLeft(2, '0')}';
}
Widget _buildEmptyState(ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
'Paste a Spotify URL to get started',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
+1684 -1118
View File
File diff suppressed because it is too large Load Diff
+310 -150
View File
@@ -1,12 +1,11 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:spotiflac_android/utils/file_access.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';
/// Screen to display tracks from a local library album
@@ -31,7 +30,6 @@ class LocalAlbumScreen extends ConsumerStatefulWidget {
class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
late List<LocalLibraryItem> _sortedTracksCache;
@@ -44,13 +42,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
super.initState();
_scrollController.addListener(_onScroll);
_rebuildTrackCaches();
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null) {
_dominantColor = cachedColor;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_extractDominantColor();
});
}
@override
@@ -60,13 +51,6 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
oldWidget.tracks.length != widget.tracks.length) {
_rebuildTrackCaches();
}
if (oldWidget.coverPath != widget.coverPath) {
final cachedColor = PaletteService.instance.getCached(widget.coverPath);
if (cachedColor != null && cachedColor != _dominantColor) {
_dominantColor = cachedColor;
}
_extractDominantColor();
}
}
@override
@@ -83,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() {
final tracks = List<LocalLibraryItem>.from(widget.tracks);
tracks.sort((a, b) {
@@ -117,7 +89,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_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>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
@@ -186,29 +160,28 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList();
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
await deleteFile(item.filePath);
} catch (_) {}
libraryNotifier.removeItem(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
// Go back if all tracks were deleted
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
@@ -219,12 +192,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Future<void> _openFile(String filePath) async {
try {
final mimeType = audioMimeTypeForPath(filePath);
await OpenFilex.open(filePath, type: mimeType);
await openFile(filePath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
@@ -235,19 +209,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache;
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: const Center(
child: Text('No tracks found for this album'),
),
appBar: AppBar(title: Text(widget.albumName)),
body: const Center(child: Text('No tracks found for this album')),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@@ -273,17 +243,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
@@ -292,12 +269,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@@ -318,27 +300,49 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover background
if (widget.coverPath != null)
Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
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 +352,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@@ -371,13 +375,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
color:
colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -386,14 +399,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
@@ -401,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(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
@@ -416,40 +441,79 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Row(
children: [
// "Local" badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer),
Icon(
Icons.folder,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
'Local',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
// Track count
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant),
Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
'${tracks.length} tracks',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
@@ -457,18 +521,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
// Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
style: TextStyle(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.onPrimaryContainer
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
@@ -489,17 +556,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
for (final track in tracks) {
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) {
if (track.bitDepth != first.bitDepth ||
track.sampleRate != first.sampleRate) {
return null;
}
}
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -507,14 +580,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(
context.l10n.downloadedAlbumTracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
@@ -522,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 hasMultipleDiscs = _hasMultipleDiscsCache;
final slivers = <Widget>[];
for (final discNumber in _sortedDiscNumbersCache) {
final discTracks = discGroups[discNumber]!;
if (hasMultipleDiscs) {
slivers.add(
SliverToBoxAdapter(
@@ -539,7 +626,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
@@ -547,14 +637,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
style: Theme.of(context).textTheme.labelLarge
?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
@@ -572,35 +667,46 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
);
}
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]),
(context, index) =>
_buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length,
),
),
);
}
return SliverMainAxisGroup(slivers: slivers);
}
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) {
Widget _buildTrackItem(
BuildContext context,
ColorScheme colorScheme,
LocalLibraryItem track,
) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
color: isSelected
? colorScheme.primaryContainer.withValues(alpha: 0.3)
: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _openFile(track.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -609,12 +715,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
@@ -636,7 +753,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
@@ -649,27 +768,45 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
if (track.format != null) ...[
Text('', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)),
Text(
'',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
trailing: _isSelectionMode
? null
: IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(
alpha: 0.3,
),
),
),
),
),
);
}
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks, double bottomPadding) {
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
@@ -716,12 +853,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
context.l10n.downloadedAlbumSelectedCount(
selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
allSelected
? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
@@ -734,9 +877,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
),
],
),
@@ -744,18 +896,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
backgroundColor: selectedCount > 0
? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),
+130 -8
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/extension_provider.dart';
@@ -12,6 +15,7 @@ import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/store_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart';
@@ -40,6 +44,7 @@ class _MainShellState extends ConsumerState<MainShell> {
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
_setupShareListener();
_checkSafMigration();
});
}
@@ -77,9 +82,9 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
}
if (!mounted) return;
Navigator.of(context).popUntil((route) => route.isFirst);
if (_currentIndex != 0) {
@@ -115,6 +120,92 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
static const _safMigrationShownKey = 'saf_migration_prompt_shown';
Future<void> _checkSafMigration() async {
if (!Platform.isAndroid) return;
final settings = ref.read(settingsProvider);
// Only show if user is still on legacy storage mode with a download dir set
if (settings.storageMode == 'saf') return;
if (settings.downloadDirectory.isEmpty) return;
// Check Android version
final deviceInfo = DeviceInfoPlugin();
final androidInfo = await deviceInfo.androidInfo;
if (androidInfo.version.sdkInt < 29) return;
// Only show once
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_safMigrationShownKey) == true) return;
await prefs.setBool(_safMigrationShownKey, true);
if (!mounted) return;
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
icon: Icon(
Icons.folder_special_outlined,
size: 32,
color: colorScheme.primary,
),
title: const Text('Storage Update Required'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SpotiFLAC now uses Android Storage Access Framework (SAF) for downloads. '
'This fixes "permission denied" errors on Android 10+.',
),
SizedBox(height: 12),
Text(
'Please select your download folder again to switch to the new storage system.',
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Later'),
),
FilledButton(
onPressed: () async {
Navigator.pop(ctx);
final result = await PlatformBridge.pickSafTree();
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) {
ref
.read(settingsProvider.notifier)
.setDownloadTreeUri(
treeUri,
displayName: displayName.isNotEmpty
? displayName
: treeUri,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Download folder updated to SAF mode'),
),
);
}
}
}
},
child: const Text('Select Folder'),
),
],
),
);
}
@override
void dispose() {
_shareSubscription?.cancel();
@@ -193,7 +284,16 @@ class _MainShellState extends ConsumerState<MainShell> {
final queueState = ref.watch(
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(
settingsProvider.select((s) => s.showExtensionStore),
);
@@ -205,10 +305,10 @@ class _MainShellState extends ConsumerState<MainShell> {
final canPop =
_currentIndex == 0 &&
!trackState.hasSearchText &&
!trackState.hasContent &&
!trackState.isLoading &&
!trackState.isShowingRecentAccess &&
!trackHasSearchText &&
!trackHasContent &&
!trackIsLoading &&
!trackIsShowingRecentAccess &&
!isKeyboardVisible;
final tabs = <Widget>[
@@ -290,7 +390,9 @@ class _MainShellState extends ConsumerState<MainShell> {
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const ClampingScrollPhysics(),
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
? const _NoSwipeRightPhysics()
: const ClampingScrollPhysics(),
children: tabs,
),
bottomNavigationBar: NavigationBar(
@@ -313,6 +415,26 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
/// Custom physics that blocks swiping to the right (next page) while
/// still allowing vertical scrolling inside the page content.
class _NoSwipeRightPhysics extends ScrollPhysics {
const _NoSwipeRightPhysics({super.parent});
@override
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
// In a horizontal PageView, a negative offset means the user is
// dragging left (i.e. trying to go to the next page / right).
// Block that direction only.
if (offset < 0) return 0.0;
return super.applyPhysicsToUserOffset(position, offset);
}
}
class BouncingIcon extends StatefulWidget {
final Widget child;
const BouncingIcon({super.key, required this.child});
+393 -135
View File
@@ -1,14 +1,15 @@
import 'dart:io';
import 'dart:ui';
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/palette_service.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -32,7 +33,6 @@ class PlaylistScreen extends ConsumerStatefulWidget {
}
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
List<Track>? _fetchedTracks;
@@ -45,7 +45,6 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
_fetchTracksIfNeeded();
}
@@ -58,26 +57,31 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Future<void> _fetchTracksIfNeeded() async {
if (widget.tracks.isNotEmpty || widget.playlistId == null) return;
setState(() {
_isLoading = true;
_error = null;
});
try {
// Extract numeric ID from "deezer:123" format
String playlistId = widget.playlistId!;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
}
final result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
final result = await PlatformBridge.getDeezerMetadata(
'playlist',
playlistId,
);
if (!mounted) return;
// Go backend returns 'track_list' not 'tracks'
final trackList = result['track_list'] as List<dynamic>? ?? [];
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
setState(() {
_fetchedTracks = tracks;
_isLoading = false;
@@ -99,7 +103,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} else if (durationValue is double) {
durationMs = durationValue.toInt();
}
return Track(
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
name: (data['name'] ?? '').toString(),
@@ -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
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
@@ -149,15 +145,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@@ -175,27 +177,52 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
// Blurred cover background
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
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,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@@ -222,15 +249,19 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant),
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@@ -239,17 +270,20 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
@@ -264,34 +298,63 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
Text(
widget.playlistName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
Icon(
Icons.playlist_play,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(_tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
context.l10n.tracksCount(_tracks.length),
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 16),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _tracks.isEmpty ? null : () => _downloadAll(context),
onPressed: _tracks.isEmpty
? null
: () => _downloadAll(context),
icon: const Icon(Icons.download, size: 18),
label: Text(context.l10n.downloadAllCount(_tracks.length)),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
],
@@ -310,7 +373,13 @@ const SizedBox(height: 16),
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.tracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(
context.l10n.tracksHeader,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
),
@@ -326,7 +395,7 @@ const SizedBox(height: 16),
),
);
}
if (_error != null) {
return SliverToBoxAdapter(
child: Padding(
@@ -339,7 +408,12 @@ const SizedBox(height: 16),
children: [
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 12),
Expanded(child: Text(_error!, style: TextStyle(color: colorScheme.error))),
Expanded(
child: Text(
_error!,
style: TextStyle(color: colorScheme.error),
),
),
],
),
),
@@ -347,7 +421,7 @@ const SizedBox(height: 16),
),
);
}
if (_tracks.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
@@ -361,21 +435,18 @@ const SizedBox(height: 16),
),
);
}
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = _tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: _tracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = _tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, track),
),
);
}, childCount: _tracks.length),
);
}
@@ -388,13 +459,23 @@ const SizedBox(height: 16),
artistName: track.artistName,
coverUrl: track.coverUrl,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
),
);
},
);
} else {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
ref
.read(downloadQueueProvider.notifier)
.addToQueue(track, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))),
);
}
}
@@ -407,13 +488,29 @@ const SizedBox(height: 16),
trackName: '${_tracks.length} tracks',
artistName: widget.playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
),
);
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(_tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(_tracks.length))));
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(_tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAddedTracksToQueue(_tracks.length),
),
),
);
}
}
}
@@ -428,34 +525,45 @@ class _PlaylistTrackItem extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final queueItem = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.byTrackId[track.id]),
downloadQueueLookupProvider.select(
(lookup) => lookup.byTrackId[track.id],
),
);
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}));
final isInHistory = ref.watch(
downloadHistoryProvider.select((state) {
return state.isDownloaded(track.id);
}),
);
// Check local library for duplicate detection
final settings = ref.watch(settingsProvider);
final showLocalLibraryIndicator = settings.localLibraryEnabled && settings.localLibraryShowDuplicates;
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(localLibraryProvider.select((state) =>
state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
)))
final showLocalLibraryIndicator = ref.watch(
settingsProvider.select(
(s) => s.localLibraryEnabled && s.localLibraryShowDuplicates,
),
);
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select(
(state) => state.existsInLibrary(
isrc: track.isrc,
trackName: track.name,
artistName: track.artistName,
),
),
)
: false;
final isQueued = queueItem != null;
final isDownloading = queueItem?.status == DownloadStatus.downloading;
final isFinalizing = queueItem?.status == DownloadStatus.finalizing;
final isCompleted = queueItem?.status == DownloadStatus.completed;
final progress = queueItem?.progress ?? 0.0;
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
final showAsDownloaded =
isCompleted || (!isQueued && isInHistory) || isInLocalLibrary;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -464,18 +572,58 @@ class _PlaylistTrackItem extends ConsumerWidget {
color: Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
leading: track.coverUrl != null
? ClipRRect(borderRadius: BorderRadius.circular(8), child: CachedNetworkImage(imageUrl: track.coverUrl!, width: 48, height: 48, fit: BoxFit.cover, memCacheWidth: 96, cacheManager: CoverCacheManager.instance))
: Container(width: 48, height: 48, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8)), child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant)),
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
leading: track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
memCacheWidth: 96,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.music_note,
color: colorScheme.onSurfaceVariant,
),
),
title: Text(
track.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
Flexible(child: Text(track.artistName, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant))),
Flexible(
child: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
),
if (isInLocalLibrary) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(4),
@@ -483,51 +631,102 @@ leading: track.coverUrl != null
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder_outlined, size: 10, color: colorScheme.onTertiaryContainer),
Icon(
Icons.folder_outlined,
size: 10,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 3),
Text(context.l10n.libraryInLibrary, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w500, color: colorScheme.onTertiaryContainer)),
Text(
context.l10n.libraryInLibrary,
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w500,
color: colorScheme.onTertiaryContainer,
),
),
],
),
),
],
],
),
trailing: _buildDownloadButton(context, ref, colorScheme, isQueued: isQueued, isDownloading: isDownloading, isFinalizing: isFinalizing, showAsDownloaded: showAsDownloaded, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary, progress: progress),
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
trailing: _buildDownloadButton(
context,
ref,
colorScheme,
isQueued: isQueued,
isDownloading: isDownloading,
isFinalizing: isFinalizing,
showAsDownloaded: showAsDownloaded,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
progress: progress,
),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
),
),
);
}
void _handleTap(BuildContext context, WidgetRef ref, {required bool isQueued, required bool isInHistory, required bool isInLocalLibrary}) async {
void _handleTap(
BuildContext context,
WidgetRef ref, {
required bool isQueued,
required bool isInHistory,
required bool isInLocalLibrary,
}) async {
if (isQueued) return;
if (isInLocalLibrary) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyInLibrary(track.name))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarAlreadyInLibrary(track.name)),
),
);
}
return;
}
if (isInHistory) {
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
final historyItem = ref
.read(downloadHistoryProvider.notifier)
.getBySpotifyId(track.id);
if (historyItem != null) {
final fileExists = await File(historyItem.filePath).exists();
if (fileExists) {
final exists = await fileExists(historyItem.filePath);
if (exists) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarAlreadyDownloaded(track.name),
),
),
);
}
return;
} else {
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
ref
.read(downloadHistoryProvider.notifier)
.removeBySpotifyId(track.id);
}
}
}
onDownload();
}
Widget _buildDownloadButton(BuildContext context, WidgetRef ref, ColorScheme colorScheme, {
Widget _buildDownloadButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme, {
required bool isQueued,
required bool isDownloading,
required bool isFinalizing,
@@ -538,11 +737,29 @@ leading: track.coverUrl != null
}) {
const double size = 44.0;
const double iconSize = 20.0;
if (showAsDownloaded) {
return GestureDetector(
onTap: () => _handleTap(context, ref, isQueued: isQueued, isInHistory: isInHistory, isInLocalLibrary: isInLocalLibrary),
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.primaryContainer, shape: BoxShape.circle), child: Icon(Icons.check, color: colorScheme.onPrimaryContainer, size: iconSize)),
onTap: () => _handleTap(
context,
ref,
isQueued: isQueued,
isInHistory: isInHistory,
isInLocalLibrary: isInLocalLibrary,
),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.check,
color: colorScheme.onPrimaryContainer,
size: iconSize,
),
),
);
} else if (isFinalizing) {
return SizedBox(
@@ -551,7 +768,11 @@ leading: track.coverUrl != null
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 3, color: colorScheme.tertiary, backgroundColor: colorScheme.surfaceContainerHighest),
CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.tertiary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 16),
],
),
@@ -563,17 +784,54 @@ leading: track.coverUrl != null
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(value: progress > 0 ? progress : null, strokeWidth: 3, color: colorScheme.primary, backgroundColor: colorScheme.surfaceContainerHighest),
if (progress > 0) Text('${(progress * 100).toInt()}', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: colorScheme.primary)),
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 3,
color: colorScheme.primary,
backgroundColor: colorScheme.surfaceContainerHighest,
),
if (progress > 0)
Text(
'${(progress * 100).toInt()}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
),
],
),
);
} else if (isQueued) {
return Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, shape: BoxShape.circle), child: Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant, size: iconSize));
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(
Icons.hourglass_empty,
color: colorScheme.onSurfaceVariant,
size: iconSize,
),
);
} else {
return GestureDetector(
onTap: onDownload,
child: Container(width: size, height: size, decoration: BoxDecoration(color: colorScheme.secondaryContainer, shape: BoxShape.circle), child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize)),
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
Icons.download,
color: colorScheme.onSecondaryContainer,
size: iconSize,
),
),
);
}
}
-248
View File
@@ -1,248 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
class QueueScreen extends ConsumerWidget {
const QueueScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.queueTitle),
actions: [
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
tooltip: context.l10n.queueClearCompleted,
),
if (items.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear_all),
onPressed: () => _showClearAllDialog(context, ref),
tooltip: context.l10n.queueClearAll,
),
],
),
body: items.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) =>
_buildQueueItem(context, ref, items[index], colorScheme),
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.queue,
size: 64,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 16),
Text(
context.l10n.queueEmpty,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
context.l10n.queueEmptySubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
),
),
],
),
);
}
Widget _buildQueueItem(BuildContext context, WidgetRef ref, DownloadItem item, ColorScheme colorScheme) {
return ListTile(
leading: item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 48,
height: 48,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
),
title: Text(item.track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
if (item.status == DownloadStatus.downloading) ...[
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: item.progress > 0 ? item.progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
),
const SizedBox(width: 8),
Text(
'${(item.progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
],
),
],
],
),
trailing: _buildStatusIcon(context, item, colorScheme),
onTap: item.status == DownloadStatus.queued
? () => ref.read(downloadQueueProvider.notifier).cancelItem(item.id)
: null,
);
}
Widget _buildStatusIcon(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
switch (item.status) {
case DownloadStatus.queued:
return Icon(Icons.hourglass_empty, color: colorScheme.onSurfaceVariant);
case DownloadStatus.downloading:
return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: item.progress,
strokeWidth: 2,
color: colorScheme.primary,
),
);
case DownloadStatus.finalizing:
return SizedBox(
width: 24,
height: 24,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(strokeWidth: 2, color: colorScheme.tertiary),
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 12),
],
),
);
case DownloadStatus.completed:
return Icon(Icons.check_circle, color: colorScheme.primary);
case DownloadStatus.failed:
return IconButton(
icon: Icon(Icons.error, color: colorScheme.error),
onPressed: () => _showErrorDialog(context, item, colorScheme),
tooltip: 'Tap to see error details',
);
case DownloadStatus.skipped:
return Icon(Icons.skip_next, color: colorScheme.primary);
}
}
void _showErrorDialog(BuildContext context, DownloadItem item, ColorScheme colorScheme) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.error, color: colorScheme.error),
const SizedBox(width: 8),
Text(context.l10n.queueDownloadFailed),
],
),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
const SizedBox(height: 16),
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
item.error ?? context.l10n.queueUnknownError,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogClose),
),
],
),
);
}
void _showClearAllDialog(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.queueClearAll),
content: Text(context.l10n.queueClearAllMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(context.l10n.dialogCancel),
),
TextButton(
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearAll();
Navigator.pop(context);
},
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
),
],
),
);
}
}
+1189 -785
View File
File diff suppressed because it is too large Load Diff
+313 -289
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget {
@@ -12,7 +13,7 @@ class AboutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
@@ -20,228 +21,229 @@ class AboutPage extends StatelessWidget {
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: _AppHeaderCard(),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutContributors,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
);
},
_ContributorItem(
name: AppInfo.originalAuthor,
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: _AppHeaderCard(),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutTranslators,
),
),
),
const SliverToBoxAdapter(child: _TranslatorsSection()),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
_ContributorItem(
name: AppInfo.originalAuthor,
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutSpecialThanks,
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutTranslators),
),
const SliverToBoxAdapter(
child: _TranslatorsSection(),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'binimum',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_ContributorItem(
name: 'sjdonado',
description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'sjdonado',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'binimum',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_ContributorItem(
name: 'sjdonado',
description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'sjdonado',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutSpotiSaver,
subtitle: context.l10n.aboutSpotiSaverDesc,
onTap: () => _launchUrl('https://spotisaver.net'),
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.phone_android,
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.computer,
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.telegram,
title: context.l10n.aboutTelegramChannel,
subtitle: context.l10n.aboutTelegramChannelSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'),
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.phone_android,
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.computer,
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.coffee_outlined,
title: context.l10n.aboutBuyMeCoffee,
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
onTap: () => _launchUrl(AppInfo.kofiUrl),
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.telegram,
title: context.l10n.aboutTelegramChannel,
subtitle: context.l10n.aboutTelegramChannelSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'),
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle:
'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
),
),
);
}
@@ -256,73 +258,93 @@ class _AppHeaderCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest;
return Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final logoSize = (shortestSide * 0.22).clamp(72.0, 88.0);
final contentPadding = (cardWidth * 0.06).clamp(16.0, 24.0);
final titleGap = (16 * (1 + ((textScale - 1) * 0.2))).clamp(12.0, 20.0);
return Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: EdgeInsets.all(contentPadding),
child: Column(
children: [
Container(
width: logoSize,
height: logoSize,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo.png',
width: 88,
height: 88,
fit: BoxFit.cover,
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: logoSize,
height: logoSize,
fit: BoxFit.cover,
),
),
),
),
),
),
const SizedBox(height: 16),
Text(
AppInfo.appName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
SizedBox(height: titleGap),
Text(
AppInfo.appName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(height: titleGap),
Text(
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 16),
Text(
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
},
);
}
}
@@ -343,7 +365,7 @@ class _ContributorItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -357,7 +379,7 @@ class _ContributorItem extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
child: CachedNetworkImage(
imageUrl: 'https://github.com/$githubUsername.png',
width: 40,
height: 40,
@@ -390,10 +412,7 @@ child: CachedNetworkImage(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(name, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(
description,
@@ -495,9 +514,12 @@ class _TranslatorsSection extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest;
return Padding(
@@ -511,9 +533,9 @@ class _TranslatorsSection extends StatelessWidget {
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _translators.map((translator) => _TranslatorChip(
translator: translator,
)).toList(),
children: _translators
.map((translator) => _TranslatorChip(translator: translator))
.toList(),
),
),
);
@@ -545,7 +567,9 @@ class _TranslatorChip extends StatelessWidget {
radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?',
translator.name.isNotEmpty
? translator.name[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
@@ -562,10 +586,7 @@ class _TranslatorChip extends StatelessWidget {
),
),
const SizedBox(width: 6),
Text(
translator.flag,
style: const TextStyle(fontSize: 14),
),
Text(translator.flag, style: const TextStyle(fontSize: 14)),
],
),
),
@@ -597,7 +618,7 @@ class _AboutSettingsItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -612,31 +633,34 @@ class _AboutSettingsItem extends StatelessWidget {
SizedBox(
width: 40,
height: 40,
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
child: Icon(
icon,
color: colorScheme.onSurfaceVariant,
size: 24,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(title, style: Theme.of(context).textTheme.bodyLarge),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
],
),
),
if (onTap != null)
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
],
),
),
+179 -169
View File
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AppearanceSettingsPage extends ConsumerWidget {
@@ -14,7 +15,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
final themeSettings = ref.watch(themeProvider);
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
@@ -22,21 +23,21 @@ class AppearanceSettingsPage extends ConsumerWidget {
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
SliverToBoxAdapter(
child: Padding(
@@ -77,8 +78,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
@@ -113,9 +114,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [
_LanguageSelector(
currentLocale: settings.locale,
onChanged: (locale) => ref
.read(settingsProvider.notifier)
.setLocale(locale),
onChanged: (locale) =>
ref.read(settingsProvider.notifier).setLocale(locale),
),
],
),
@@ -156,151 +156,167 @@ class _ThemePreviewCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark;
return RepaintBoundary(
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned(
top: -50,
right: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
),
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final previewHeight = (cardWidth * 0.56).clamp(170.0, 220.0);
final innerWidth = (cardWidth - 48).clamp(220.0, 320.0);
final innerHeight = (previewHeight * 0.70).clamp(120.0, 160.0);
final innerPadding = (innerHeight * 0.11).clamp(12.0, 18.0);
final artworkSize = (innerHeight - (innerPadding * 2)).clamp(
80.0,
120.0,
);
Center(
child: Container(
width: 260,
height: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
width: 108,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
return Container(
constraints: BoxConstraints(minHeight: previewHeight),
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned(
top: -(previewHeight * 0.25),
right: -(previewHeight * 0.25),
child: Container(
width: previewHeight,
height: previewHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(
alpha: 0.5,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
),
Positioned(
bottom: -(previewHeight * 0.15),
left: -(previewHeight * 0.15),
child: Container(
width: previewHeight * 0.75,
height: previewHeight * 0.75,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(
alpha: 0.5,
),
),
),
),
Center(
child: Container(
width: innerWidth,
height: innerHeight,
padding: EdgeInsets.all(innerPadding),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
width: artworkSize,
height: artworkSize,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
const SizedBox(height: 8),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: artworkSize * 0.44,
),
const SizedBox(height: 24),
Row(
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
const SizedBox(height: 8),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
const SizedBox(height: 24),
Row(
children: [
Icon(
Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
),
],
),
],
),
],
),
),
],
),
],
),
),
),
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark
? context.l10n.appearanceThemeDark
: context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
],
),
);
},
),
);
}
@@ -498,7 +514,7 @@ class _ThemeModeChip extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
@@ -694,7 +710,7 @@ class _LanguageSelector extends StatelessWidget {
required this.onChanged,
});
static const _allLanguages = [
static const _allLanguages = [
('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language),
@@ -735,16 +751,10 @@ static const _allLanguages = [
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(
Icons.language,
color: colorScheme.onSurfaceVariant,
),
leading: Icon(Icons.language, color: colorScheme.onSurfaceVariant),
title: Text(context.l10n.appearanceLanguage),
subtitle: Text(_getLanguageName(currentLocale)),
trailing: Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: () => _showLanguagePicker(context),
);
}
@@ -765,9 +775,9 @@ static const _allLanguages = [
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.appearanceLanguage,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
const Divider(height: 1),
@@ -781,22 +791,22 @@ static const _allLanguages = [
return ListTile(
leading: Icon(
lang.$3,
color: isSelected
? colorScheme.primary
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
lang.$2,
style: TextStyle(
color: isSelected
? colorScheme.primary
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
fontWeight: isSelected
? FontWeight.w600
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
trailing: isSelected
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
@@ -0,0 +1,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,
});
}
+438
View File
@@ -0,0 +1,438 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/donate_icons.dart';
class DonatePage extends StatelessWidget {
const DonatePage({super.key});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
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),
),
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(
'Donate',
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Donate links card
_DonateLinksCard(colorScheme: colorScheme),
const SizedBox(height: 24),
// Recent donors section
_RecentDonorsCard(colorScheme: colorScheme),
const SizedBox(height: 16),
// Combined notice card
Card(
elevation: 0,
color: colorScheme.secondaryContainer.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.volunteer_activism_rounded,
size: 20,
color: colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Good to Know',
style: Theme.of(context).textTheme.titleSmall
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 10),
_NoticeLine(
icon: Icons.block,
text: 'Not selling early access, premium features, or paywalls',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.build_outlined,
text: 'Funds go to dev tools & testing devices',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.favorite_border,
text: 'Your support is the only way to keep this project alive',
colorScheme: colorScheme,
),
Divider(
height: 24,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_NoticeLine(
icon: Icons.history,
text: 'Your name stays permanently in every version it was included in',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.update,
text: 'Supporter list is updated monthly and embedded in the app',
colorScheme: colorScheme,
),
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.cloud_off,
text: 'No remote server -- everything is stored locally',
colorScheme: colorScheme,
),
],
),
),
),
],
),
),
),
],
),
);
}
}
class _RecentDonorsCard extends StatelessWidget {
final ColorScheme colorScheme;
const _RecentDonorsCard({required this.colorScheme});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
// Match SettingsGroup color logic
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Card(
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.star_rounded, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(
'Recent Supporters',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
],
),
const SizedBox(height: 4),
Text(
'Thank you for your generosity!',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
_DonorTile(name: 'J', colorScheme: colorScheme),
_DonorTile(name: 'Julian', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile(
name: '283Fabio',
colorScheme: colorScheme,
showDivider: false,
),
],
),
),
);
}
}
class _DonateLinksCard extends StatelessWidget {
final ColorScheme colorScheme;
const _DonateLinksCard({required this.colorScheme});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.04),
colorScheme.surface,
);
return Card(
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
_DonateCardItem(
title: 'Ko-fi',
subtitle: 'ko-fi.com/zarzet',
customIcon: const KofiIcon(size: 22, color: Colors.white),
color: const Color(0xFFFF5E5B),
url: AppInfo.kofiUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_DonateCardItem(
title: 'Buy Me a Coffee',
subtitle: 'buymeacoffee.com/zarzet',
customIcon: const BmacIcon(size: 22, color: Colors.black87),
color: const Color(0xFFFFDD00),
url: AppInfo.bmacUrl,
colorScheme: colorScheme,
),
Divider(
height: 1,
thickness: 1,
indent: 74,
endIndent: 16,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
_DonateCardItem(
title: 'GitHub Sponsors',
subtitle: 'github.com/sponsors/zarzet',
customIcon: const GitHubIcon(size: 22, color: Colors.white),
color: const Color(0xFF2D333B),
url: AppInfo.githubSponsorsUrl,
colorScheme: colorScheme,
),
],
),
);
}
}
class _DonateCardItem extends StatelessWidget {
final String title;
final String subtitle;
final Widget customIcon;
final Color color;
final String url;
final ColorScheme colorScheme;
const _DonateCardItem({
required this.title,
required this.subtitle,
required this.customIcon,
required this.color,
required this.url,
required this.colorScheme,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () =>
launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
),
child: Center(child: customIcon),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.open_in_new,
size: 18,
color: colorScheme.onSurfaceVariant,
),
],
),
),
);
}
}
class _DonorTile extends StatelessWidget {
final String name;
final ColorScheme colorScheme;
final bool showDivider;
const _DonorTile({
required this.name,
required this.colorScheme,
this.showDivider = true,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: colorScheme.primaryContainer,
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 12),
Text(
name,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurface),
),
],
),
),
if (showDivider)
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
),
],
);
}
}
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,
),
),
),
],
);
}
}
+373 -193
View File
@@ -8,17 +8,21 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerStatefulWidget {
const DownloadSettingsPage({super.key});
@override
ConsumerState<DownloadSettingsPage> createState() => _DownloadSettingsPageState();
ConsumerState<DownloadSettingsPage> createState() =>
_DownloadSettingsPageState();
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
static const _builtInServices = ['tidal', 'qobuz'];
int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false;
@@ -91,8 +95,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
@@ -102,43 +106,43 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.downloadTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
),
);
},
title: Text(
context.l10n.downloadTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionService),
@@ -157,7 +161,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
child: SettingsSectionHeader(
title: context.l10n.sectionAudioQuality,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -165,7 +171,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
SettingsSwitchItem(
icon: Icons.tune,
title: context.l10n.downloadAskBeforeDownload,
subtitle: isBuiltInService
subtitle: isBuiltInService
? context.l10n.downloadAskQualitySubtitle
: 'Select a built-in service to enable',
value: settings.askQualityBeforeDownload,
@@ -174,7 +180,8 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
if (!settings.askQualityBeforeDownload &&
isBuiltInService) ...[
_QualityOption(
title: context.l10n.qualityFlacLossless,
subtitle: context.l10n.qualityFlacLosslessSubtitle,
@@ -204,7 +211,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (isTidalService)
_QualityOption(
title: 'Lossy 320kbps',
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
subtitle: _getTidalHighFormatLabel(
settings.tidalHighFormat,
),
isSelected: settings.audioQuality == 'HIGH',
onTap: () => ref
.read(settingsProvider.notifier)
@@ -215,8 +224,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
SettingsItem(
icon: Icons.tune,
title: 'Lossy Format',
subtitle: _getTidalHighFormatLabel(settings.tidalHighFormat),
onTap: () => _showTidalHighFormatPicker(context, ref, settings.tidalHighFormat),
subtitle: _getTidalHighFormatLabel(
settings.tidalHighFormat,
),
onTap: () => _showTidalHighFormatPicker(
context,
ref,
settings.tidalHighFormat,
),
showDivider: false,
),
],
@@ -233,44 +248,47 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Select Tidal, Qobuz, or Amazon above to configure quality',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
'Select Tidal or Qobuz above to configure quality',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
],
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.lyrics_outlined,
title: context.l10n.lyricsMode,
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
onTap: () => _showLyricsModePicker(
context,
ref,
settings.lyricsMode,
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionLyrics),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.lyrics_outlined,
title: context.l10n.lyricsMode,
subtitle: _getLyricsModeLabel(context, settings.lyricsMode),
onTap: () => _showLyricsModePicker(
context,
ref,
settings.lyricsMode,
),
showDivider: false,
),
showDivider: false,
),
],
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.sectionFileSettings,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
@@ -309,7 +327,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.downloadAlbumFolderStructure,
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
subtitle: _getAlbumFolderStructureLabel(
settings.albumFolderStructure,
),
onTap: () => _showAlbumFolderStructurePicker(
context,
ref,
@@ -328,7 +348,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ref,
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,
),
],
),
@@ -347,7 +382,11 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
subtitle: settings.downloadNetworkMode == 'wifi_only'
? context.l10n.settingsDownloadNetworkWifiOnly
: context.l10n.settingsDownloadNetworkAny,
onTap: () => _showNetworkModePicker(context, ref, settings.downloadNetworkMode),
onTap: () => _showNetworkModePicker(
context,
ref,
settings.downloadNetworkMode,
),
),
SettingsSwitchItem(
icon: Icons.file_download_outlined,
@@ -355,7 +394,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
value: settings.autoExportFailedDownloads,
onChanged: (value) {
ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value);
ref
.read(settingsProvider.notifier)
.setAutoExportFailedDownloads(value);
},
showDivider: false,
),
@@ -366,7 +407,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
// All Files Access section (Android 13+ only)
if (Platform.isAndroid && _androidSdkVersion >= 33) ...[
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionStorageAccess),
child: SettingsSectionHeader(
title: context.l10n.sectionStorageAccess,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -405,16 +448,15 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Expanded(
child: Text(
context.l10n.allFilesAccessDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
],
),
),
),
],
],
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
@@ -438,7 +480,11 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
}
}
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
void _showAlbumFolderStructurePicker(
BuildContext context,
WidgetRef ref,
String current,
) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
@@ -449,9 +495,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
leading: const Icon(Icons.folder_outlined),
title: Text(context.l10n.albumFolderArtistAlbum),
subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle),
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
trailing: current == 'artist_album'
? const Icon(Icons.check)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
ref
.read(settingsProvider.notifier)
.setAlbumFolderStructure('artist_album');
Navigator.pop(context);
},
),
@@ -459,9 +509,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
leading: const Icon(Icons.calendar_today_outlined),
title: Text(context.l10n.albumFolderArtistYearAlbum),
subtitle: Text(context.l10n.albumFolderArtistYearAlbumSubtitle),
trailing: current == 'artist_year_album' ? const Icon(Icons.check) : null,
trailing: current == 'artist_year_album'
? const Icon(Icons.check)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
ref
.read(settingsProvider.notifier)
.setAlbumFolderStructure('artist_year_album');
Navigator.pop(context);
},
),
@@ -469,9 +523,13 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
leading: const Icon(Icons.album_outlined),
title: Text(context.l10n.albumFolderAlbumOnly),
subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle),
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
trailing: current == 'album_only'
? const Icon(Icons.check)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
ref
.read(settingsProvider.notifier)
.setAlbumFolderStructure('album_only');
Navigator.pop(context);
},
),
@@ -479,19 +537,29 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
leading: const Icon(Icons.event_outlined),
title: Text(context.l10n.albumFolderYearAlbum),
subtitle: Text(context.l10n.albumFolderYearAlbumSubtitle),
trailing: current == 'year_album' ? const Icon(Icons.check) : null,
trailing: current == 'year_album'
? const Icon(Icons.check)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
ref
.read(settingsProvider.notifier)
.setAlbumFolderStructure('year_album');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.person_outlined),
title: Text(context.l10n.albumFolderArtistAlbumSingles),
subtitle: Text(context.l10n.albumFolderArtistAlbumSinglesSubtitle),
trailing: current == 'artist_album_singles' ? const Icon(Icons.check) : null,
subtitle: Text(
context.l10n.albumFolderArtistAlbumSinglesSubtitle,
),
trailing: current == 'artist_album_singles'
? const Icon(Icons.check)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album_singles');
ref
.read(settingsProvider.notifier)
.setAlbumFolderStructure('artist_album_singles');
Navigator.pop(context);
},
),
@@ -634,7 +702,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Row(
children: [
Expanded(
child: TextButton(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
@@ -680,13 +748,123 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (Platform.isIOS) {
_showIOSDirectoryOptions(context, ref);
} else {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
_showAndroidDirectoryOptions(context, ref);
}
}
Future<String> _getDefaultAndroidDirectory() async {
final directMusicPath = '/storage/emulated/0/Music/SpotiFLAC';
try {
final musicDir = Directory(directMusicPath);
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
return musicDir.path;
} catch (_) {}
try {
final externalDir = await getExternalStorageDirectory();
if (externalDir != null) {
final musicDir = Directory(
'${externalDir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
);
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
return musicDir.path;
}
} catch (_) {}
final appDir = await getApplicationDocumentsDirectory();
final fallbackDir = Directory('${appDir.path}/SpotiFLAC');
if (!await fallbackDir.exists()) {
await fallbackDir.create(recursive: true);
}
return fallbackDir.path;
}
void _showAndroidDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
final isSafMode =
settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Download Location',
style: Theme.of(
ctx,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose storage mode for downloaded files.',
style: Theme.of(ctx).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App folder (non-SAF)'),
subtitle: const Text('Use default Music/SpotiFLAC path'),
trailing: !isSafMode ? const Icon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
final defaultDir = await _getDefaultAndroidDirectory();
final notifier = ref.read(settingsProvider.notifier);
notifier.setStorageMode('app');
notifier.setDownloadDirectory(defaultDir);
notifier.setDownloadTreeUri('');
},
),
ListTile(
leading: Icon(Icons.folder_open, color: colorScheme.primary),
title: const Text('SAF folder'),
subtitle: const Text(
'Pick folder via Android Storage Access Framework',
),
trailing: isSafMode ? const Icon(Icons.check) : null,
onTap: () async {
Navigator.pop(ctx);
final result = await PlatformBridge.pickSafTree();
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
final displayName = result['display_name'] as String? ?? '';
if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setStorageMode('saf');
ref
.read(settingsProvider.notifier)
.setDownloadTreeUri(
treeUri,
displayName: displayName.isNotEmpty
? displayName
: treeUri,
);
}
}
},
),
const SizedBox(height: 8),
],
),
),
);
}
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
@@ -731,7 +909,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) Navigator.pop(ctx);
},
),
ListTile(
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: Text(context.l10n.setupChooseFromFiles),
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
@@ -740,16 +918,14 @@ ListTile(
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
// iOS: Check if user selected iCloud Drive (not accessible by Go backend)
// iOS: Validate the selected path is writable (not iCloud or container root)
if (Platform.isIOS) {
final isICloudPath = result.contains('Mobile Documents') ||
result.contains('CloudDocs') ||
result.contains('com~apple~CloudDocs');
if (isICloudPath) {
final validation = validateIosPath(result);
if (!validation.isValid) {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text(context.l10n.setupIcloudNotSupported),
content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
@@ -899,6 +1075,8 @@ ListTile(
switch (format) {
case 'mp3_320':
return 'MP3 320kbps';
case 'opus_256':
return 'Opus 256kbps';
case 'opus_128':
return 'Opus 128kbps';
default:
@@ -945,19 +1123,41 @@ ListTile(
leading: const Icon(Icons.audiotrack),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: current == 'mp3_320' ? Icon(Icons.check, color: colorScheme.primary) : null,
trailing: current == 'mp3_320'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('mp3_320');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 256kbps'),
subtitle: const Text('Best quality Opus, ~8MB per track'),
trailing: current == 'opus_256'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_256');
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Opus 128kbps'),
subtitle: const Text('Modern codec, ~4MB per track'),
trailing: current == 'opus_128' ? Icon(Icons.check, color: colorScheme.primary) : null,
subtitle: const Text('Smallest size, ~4MB per track'),
trailing: current == 'opus_128'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
ref
.read(settingsProvider.notifier)
.setTidalHighFormat('opus_128');
Navigator.pop(context);
},
),
@@ -1007,9 +1207,13 @@ ListTile(
leading: const Icon(Icons.signal_cellular_alt),
title: Text(context.l10n.settingsDownloadNetworkAny),
subtitle: const Text('WiFi + Mobile Data'),
trailing: current == 'any' ? Icon(Icons.check, color: colorScheme.primary) : null,
trailing: current == 'any'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setDownloadNetworkMode('any');
ref
.read(settingsProvider.notifier)
.setDownloadNetworkMode('any');
Navigator.pop(context);
},
),
@@ -1017,9 +1221,13 @@ ListTile(
leading: const Icon(Icons.wifi),
title: Text(context.l10n.settingsDownloadNetworkWifiOnly),
subtitle: const Text('Pause downloads on mobile data'),
trailing: current == 'wifi_only' ? Icon(Icons.check, color: colorScheme.primary) : null,
trailing: current == 'wifi_only'
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setDownloadNetworkMode('wifi_only');
ref
.read(settingsProvider.notifier)
.setDownloadNetworkMode('wifi_only');
Navigator.pop(context);
},
),
@@ -1076,7 +1284,9 @@ ListTile(
example: 'SpotiFLAC/Track.flac',
isSelected: current == 'none',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('none');
ref
.read(settingsProvider.notifier)
.setFolderOrganization('none');
Navigator.pop(context);
},
),
@@ -1086,7 +1296,9 @@ ListTile(
example: 'SpotiFLAC/Artist Name/Track.flac',
isSelected: current == 'artist',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
ref
.read(settingsProvider.notifier)
.setFolderOrganization('artist');
Navigator.pop(context);
},
),
@@ -1096,7 +1308,9 @@ ListTile(
example: 'SpotiFLAC/Album Name/Track.flac',
isSelected: current == 'album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('album');
ref
.read(settingsProvider.notifier)
.setFolderOrganization('album');
Navigator.pop(context);
},
),
@@ -1106,7 +1320,9 @@ ListTile(
example: 'SpotiFLAC/Artist/Album/Track.flac',
isSelected: current == 'artist_album',
onTap: () {
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
ref
.read(settingsProvider.notifier)
.setFolderOrganization('artist_album');
Navigator.pop(context);
},
),
@@ -1130,18 +1346,21 @@ class _ServiceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
.toList();
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService
final isExtensionService = ![
'tidal',
'qobuz',
].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService)
: true;
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
return Padding(
padding: const EdgeInsets.all(12),
child: Column(
@@ -1161,14 +1380,6 @@ class _ServiceSelector extends ConsumerWidget {
isSelected: effectiveService == 'qobuz',
onTap: () => onChanged('qobuz'),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
isDisabled: true,
onTap: () {},
),
],
),
if (extensionProviders.isNotEmpty) ...[
@@ -1204,15 +1415,11 @@ class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
final bool isDisabled;
final String? disabledReason;
const _ServiceChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
this.isDisabled = false,
this.disabledReason,
});
@override
@@ -1227,66 +1434,39 @@ class _ServiceChip extends StatelessWidget {
)
: colorScheme.surfaceContainerHigh;
final disabledColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.02),
colorScheme.surface,
)
: colorScheme.surfaceContainerLow;
return Expanded(
child: Tooltip(
message: isDisabled && disabledReason != null ? disabledReason! : '',
child: Material(
color: isDisabled
? disabledColor
: isSelected
? colorScheme.primaryContainer
: unselectedColor,
child: Material(
color: isSelected
? colorScheme.primaryContainer
: unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: isDisabled ? null : onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
color: isDisabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(
children: [
Icon(
icon,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected && !isDisabled
? FontWeight.w600
: FontWeight.normal,
color: isDisabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
if (isDisabled && disabledReason != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
disabledReason!,
style: TextStyle(
fontSize: 9,
color: colorScheme.onSurface.withValues(alpha: 0.38),
),
),
),
],
),
),
],
),
),
),
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget {
@@ -55,7 +56,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final hasError = extension.status == 'error';
return PopScope(
+2 -1
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionsPage extends ConsumerStatefulWidget {
@@ -51,7 +52,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true, // Always allow back gesture
+170 -39
View File
@@ -7,6 +7,8 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget {
@@ -21,6 +23,27 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
int _androidSdkVersion = 0;
bool _hasStoragePermission = false;
/// Convert SAF content URI to a readable display path
String _getDisplayPath(String path) {
if (!path.startsWith('content://')) return path;
// Extract the path portion from SAF tree URI
// e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic
// -> /storage/emulated/0/Music
try {
final uri = Uri.parse(path);
final treePath =
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
}
// For SD card or other volumes, just show the decoded path
return decoded;
} catch (_) {
return path;
}
}
@override
void initState() {
super.initState();
@@ -33,19 +56,19 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final androidInfo = await deviceInfo.androidInfo;
final sdkVersion = androidInfo.version.sdkInt;
// Check appropriate storage permission based on Android version
bool hasPermission;
if (sdkVersion >= 30) {
hasPermission = await Permission.manageExternalStorage.isGranted;
} else {
hasPermission = await Permission.storage.isGranted;
}
if (mounted) {
setState(() {
_androidSdkVersion = sdkVersion;
_hasStoragePermission = hasPermission;
// SAF doesn't need storage permission on Android 10+
_hasStoragePermission = sdkVersion >= 29 ? true : false;
});
// For older Android, check legacy storage permission
if (sdkVersion < 29) {
final hasPermission = await Permission.storage.isGranted;
if (mounted) {
setState(() => _hasStoragePermission = hasPermission);
}
}
}
} else if (Platform.isIOS) {
// iOS doesn't need explicit storage permission for app documents
@@ -55,13 +78,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
Future<bool> _requestStoragePermission() async {
if (Platform.isIOS) return true;
// SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE
if (_androidSdkVersion >= 29) return true;
PermissionStatus status;
if (_androidSdkVersion >= 30) {
status = await Permission.manageExternalStorage.request();
} else {
status = await Permission.storage.request();
}
final status = await Permission.storage.request();
if (status.isGranted) {
setState(() => _hasStoragePermission = true);
@@ -94,19 +114,30 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
}
Future<void> _pickLibraryFolder() async {
// Request permission first
if (!_hasStoragePermission) {
final granted = await _requestStoragePermission();
if (!granted) return;
}
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
if (Platform.isAndroid && _androidSdkVersion >= 29) {
// Use SAF tree picker - no MANAGE_EXTERNAL_STORAGE needed
final result = await PlatformBridge.pickSafTree();
if (result != null) {
final treeUri = result['tree_uri'] as String? ?? '';
if (treeUri.isNotEmpty) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(treeUri);
}
}
} else {
// Legacy: request permission and use file picker for older Android / iOS
if (!_hasStoragePermission) {
final granted = await _requestStoragePermission();
if (!granted) return;
}
// Fallback for older devices
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setLocalLibraryPath(result);
}
}
}
Future<void> _startScan() async {
Future<void> _startScan({bool forceFullScan = false}) async {
final settings = ref.read(settingsProvider);
final libraryPath = settings.localLibraryPath;
@@ -117,7 +148,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
if (!await Directory(libraryPath).exists()) {
if (!libraryPath.startsWith('content://') &&
!await Directory(libraryPath).exists()) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.libraryFolderNotExist)),
@@ -126,7 +158,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
await ref.read(localLibraryProvider.notifier).startScan(libraryPath);
await ref
.read(localLibraryProvider.notifier)
.startScan(libraryPath, forceFullScan: forceFullScan);
}
Future<void> _cancelScan() async {
@@ -183,7 +217,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final settings = ref.watch(settingsProvider);
final libraryState = ref.watch(localLibraryProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: CustomScrollView(
@@ -227,10 +261,12 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter(
child: _LibraryHeroCard(
itemCount: libraryState.items.length,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning,
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
scanTotalFiles: libraryState.scanTotalFiles,
scannedFiles: libraryState.scannedFiles,
lastScannedAt: libraryState.lastScannedAt,
),
),
@@ -262,7 +298,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
title: context.l10n.libraryFolder,
subtitle: settings.localLibraryPath.isEmpty
? context.l10n.libraryFolderHint
: settings.localLibraryPath,
: _getDisplayPath(settings.localLibraryPath),
onTap: settings.localLibraryEnabled
? _pickLibraryFolder
: null,
@@ -290,6 +326,58 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.libraryActions),
),
if (libraryState.scanWasCancelled)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(
alpha: 0.6,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber_outlined,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Scan cancelled',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
),
const SizedBox(height: 2),
Text(
'You can retry the scan when ready.',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.8),
),
),
],
),
),
TextButton(
onPressed: _startScan,
child: Text(context.l10n.dialogRetry),
),
],
),
),
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
@@ -300,7 +388,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
totalFiles: libraryState.scanTotalFiles,
onCancel: _cancelScan,
)
else
else ...[
Opacity(
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
@@ -314,6 +402,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
: null,
),
),
Opacity(
opacity: settings.localLibraryPath.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
icon: Icons.sync,
title: context.l10n.libraryForceFullScan,
subtitle: context.l10n.libraryForceFullScanSubtitle,
onTap: settings.localLibraryPath.isNotEmpty
? () => _startScan(forceFullScan: true)
: null,
),
),
],
Opacity(
opacity: libraryState.items.isNotEmpty ? 1.0 : 0.5,
child: SettingsItem(
@@ -400,18 +500,22 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
class _LibraryHeroCard extends StatelessWidget {
final int itemCount;
final int excludedDownloadedCount;
final bool isScanning;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
final int scannedFiles;
final DateTime? lastScannedAt;
const _LibraryHeroCard({
required this.itemCount,
required this.excludedDownloadedCount,
required this.isScanning,
required this.scanProgress,
this.scanCurrentFile,
required this.scanTotalFiles,
required this.scannedFiles,
this.lastScannedAt,
});
@@ -432,10 +536,13 @@ class _LibraryHeroCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final displayCount = isScanning
? scannedFiles
: itemCount + excludedDownloadedCount;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 220,
constraints: const BoxConstraints(minHeight: 220),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
@@ -531,12 +638,12 @@ class _LibraryHeroCard extends StatelessWidget {
),
],
),
const Spacer(),
const SizedBox(height: 16),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
itemCount.toString(),
displayCount.toString(),
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
@@ -548,16 +655,36 @@ class _LibraryHeroCard extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
context.l10n
.libraryTracksCount(itemCount)
.replaceAll(itemCount.toString(), '')
.trim(), // Getting just the label part if possible, or just use the full string if not
isScanning
? context.l10n
.libraryTracksCount(scannedFiles)
.replaceAll(scannedFiles.toString(), '')
.trim()
: context.l10n
.libraryTracksCount(displayCount)
.replaceAll(displayCount.toString(), '')
.trim(),
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
if (!isScanning && excludedDownloadedCount > 0) ...[
const SizedBox(height: 4),
Text(
'$excludedDownloadedCount from Downloads history '
'(excluded from list)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.8,
),
),
),
],
if (isScanning && scanCurrentFile != null) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
@@ -573,7 +700,9 @@ class _LibraryHeroCard extends StatelessWidget {
Icon(
Icons.history,
size: 14,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
const SizedBox(width: 6),
Text(
@@ -582,7 +711,9 @@ class _LibraryHeroCard extends StatelessWidget {
),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
),
],
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -126,7 +127,7 @@ class _LogScreenState extends State<LogScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final logs = _filteredLogs;
return PopScope(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
const MetadataProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: !_hasChanges,
+167 -57
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class OptionsSettingsPage extends ConsumerWidget {
@@ -16,7 +17,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final extensionState = ref.watch(extensionProvider);
final hasExtensions = extensionState.extensions.isNotEmpty;
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true, // Always allow back gesture
@@ -24,46 +25,48 @@ class OptionsSettingsPage extends ConsumerWidget {
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
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); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
),
);
},
title: Text(
context.l10n.optionsTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
child: SettingsSectionHeader(
title: context.l10n.sectionSearchSource,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -86,14 +89,18 @@ class OptionsSettingsPage extends ConsumerWidget {
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(context).colorScheme.onErrorContainer,
color: Theme.of(
context,
).colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.optionsSpotifyWarning,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
color: Theme.of(
context,
).colorScheme.onErrorContainer,
fontSize: 12,
),
),
@@ -107,7 +114,11 @@ class OptionsSettingsPage extends ConsumerWidget {
icon: Icons.key,
title: context.l10n.optionsSpotifyCredentials,
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,
onTap: () =>
_showSpotifyCredentialsDialog(context, ref, settings),
@@ -168,7 +179,9 @@ class OptionsSettingsPage extends ConsumerWidget {
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
child: SettingsSectionHeader(
title: context.l10n.sectionPerformance,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
@@ -222,6 +235,12 @@ class OptionsSettingsPage extends ConsumerWidget {
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.cleanupOrphanedDownloads,
subtitle: context.l10n.cleanupOrphanedDownloadsSubtitle,
onTap: () => _cleanupOrphanedDownloads(context, ref),
),
SettingsItem(
icon: Icons.delete_forever,
title: context.l10n.optionsClearHistory,
@@ -271,9 +290,7 @@ class OptionsSettingsPage extends ConsumerWidget {
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.dialogClearHistoryTitle),
content: Text(
context.l10n.dialogClearHistoryMessage,
),
content: Text(context.l10n.dialogClearHistoryMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@@ -283,17 +300,66 @@ class OptionsSettingsPage extends ConsumerWidget {
onPressed: () {
ref.read(downloadHistoryProvider.notifier).clearHistory();
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared)));
ScaffoldMessenger.of(context).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),
),
),
],
),
);
}
Future<void> _cleanupOrphanedDownloads(
BuildContext context,
WidgetRef ref,
) async {
// Show loading indicator
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
content: Row(
children: [
const CircularProgressIndicator(),
const SizedBox(width: 16),
Text(context.l10n.cleanupOrphanedDownloads),
],
),
),
);
try {
final removed = await ref
.read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads();
if (context.mounted) {
Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
removed > 0
? context.l10n.cleanupOrphanedDownloadsResult(removed)
: context.l10n.cleanupOrphanedDownloadsNone,
),
),
);
}
} catch (e) {
if (context.mounted) {
Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
}
void _showSpotifyCredentialsDialog(
BuildContext context,
WidgetRef ref,
@@ -441,7 +507,11 @@ class OptionsSettingsPage extends ConsumerWidget {
.setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)),
SnackBar(
content: Text(
context.l10n.snackbarCredentialsSaved,
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
@@ -472,7 +542,11 @@ class OptionsSettingsPage extends ConsumerWidget {
.clearSpotifyCredentials();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)),
SnackBar(
content: Text(
context.l10n.snackbarCredentialsCleared,
),
),
);
},
style: TextButton.styleFrom(
@@ -530,7 +604,9 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
Text(
currentValue == 1
? context.l10n.optionsConcurrentSequential
: context.l10n.optionsConcurrentParallel(currentValue),
: context.l10n.optionsConcurrentParallel(
currentValue,
),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -560,6 +636,18 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
isSelected: currentValue == 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),
@@ -785,20 +873,21 @@ class _MetadataSourceSelector extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
Extension? activeExtension;
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) {
activeExtension = extState.extensions
.where((e) => e.id == settings.searchProvider && e.enabled)
.firstOrNull;
}
final hasExtensionSearch = activeExtension != null;
String? extensionName;
if (hasExtensionSearch) {
extensionName = activeExtension.displayName;
}
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
@@ -816,8 +905,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
? context.l10n.optionsUsingExtension(extensionName!)
: context.l10n.optionsPrimaryProviderSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: hasExtensionSearch
? colorScheme.primary
color: hasExtensionSearch
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
@@ -870,6 +959,27 @@ class _MetadataSourceSelector extends ConsumerWidget {
],
),
],
if (currentSource == 'spotify' && !hasExtensionSearch) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 16,
color: colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.optionsSpotifyDeprecationWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
],
),
);
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: !_hasChanges,
+28 -8
View File
@@ -8,7 +8,10 @@ import 'package:spotiflac_android/screens/settings/extensions_page.dart';
import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/cache_management_page.dart';
import 'package:spotiflac_android/screens/settings/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget {
@@ -17,7 +20,7 @@ class SettingsTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView(
slivers: [
@@ -72,25 +75,41 @@ class SettingsTab extends ConsumerWidget {
icon: Icons.download_outlined,
title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
onTap: () =>
_navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.library_music_outlined,
title: l10n.settingsLocalLibrary,
subtitle: l10n.settingsLocalLibrarySubtitle,
onTap: () => _navigateTo(context, const LibrarySettingsPage()),
onTap: () =>
_navigateTo(context, const LibrarySettingsPage()),
),
SettingsItem(
icon: Icons.storage_outlined,
title: l10n.settingsCache,
subtitle: l10n.settingsCacheSubtitle,
onTap: () =>
_navigateTo(context, const CacheManagementPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
onTap: () =>
_navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
title: l10n.settingsExtensions,
subtitle: l10n.settingsExtensionsSubtitle,
onTap: () => _navigateTo(context, const ExtensionsPage()),
),
SettingsItem(
icon: Icons.favorite_outline,
title: 'Donate',
subtitle: 'Support SpotiFLAC-Mobile development',
onTap: () => _navigateTo(context, const DonatePage()),
showDivider: false,
),
],
@@ -131,7 +150,7 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
@@ -139,9 +158,10 @@ class SettingsTab extends ConsumerWidget {
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var tween = Tween(
begin: begin,
end: end,
).chain(CurveTween(curve: curve));
return SlideTransition(
position: animation.drive(tween),
child: child,
-540
View File
@@ -1,540 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
],
),
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (Lossless)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), // Spotify Green
const Color(0xFF6750A4), // Purple
const Color(0xFF0061A4), // Blue
const Color(0xFF006E1C), // Green
const Color(0xFFBA1A1A), // Red
const Color(0xFF984061), // Pink
const Color(0xFF7D5260), // Brown
const Color(0xFF006874), // Teal
const Color(0xFFFF6F00), // Orange
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: isSelected
? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3)
: null,
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
hintText: '{artist} - {title}',
),
),
const SizedBox(height: 16),
Text(
'Available placeholders:',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'{title}, {artist}, {album}, {track}, {year}, {disc}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
-520
View File
@@ -1,520 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
class SettingsTab extends ConsumerStatefulWidget {
const SettingsTab({super.key});
@override
ConsumerState<SettingsTab> createState() => _SettingsTabState();
}
class _SettingsTabState extends ConsumerState<SettingsTab> with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
final settings = ref.watch(settingsProvider);
final themeSettings = ref.watch(themeProvider);
final colorScheme = Theme.of(context).colorScheme;
return ListView(
children: [
// Theme Section
_buildSectionHeader(context, 'Appearance', colorScheme),
// Theme Mode
ListTile(
leading: Icon(Icons.brightness_6, color: colorScheme.primary),
title: const Text('Theme Mode'),
subtitle: Text(_getThemeModeName(themeSettings.themeMode)),
onTap: () => _showThemeModePicker(context, ref, themeSettings.themeMode),
),
// Dynamic Color Toggle
SwitchListTile(
secondary: Icon(Icons.palette, color: colorScheme.primary),
title: const Text('Dynamic Color'),
subtitle: const Text('Use colors from your wallpaper'),
value: themeSettings.useDynamicColor,
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
),
// Seed Color Picker (only when dynamic color is disabled)
if (!themeSettings.useDynamicColor)
ListTile(
leading: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Color(themeSettings.seedColorValue),
shape: BoxShape.circle,
border: Border.all(color: colorScheme.outline),
),
),
title: const Text('Accent Color'),
subtitle: const Text('Choose your preferred color'),
onTap: () => _showColorPicker(context, ref, themeSettings.seedColorValue),
),
const Divider(),
// Download Section
_buildSectionHeader(context, 'Download', colorScheme),
// Download Service
ListTile(
leading: Icon(Icons.cloud_download, color: colorScheme.primary),
title: const Text('Default Service'),
subtitle: Text(_getServiceName(settings.defaultService)),
onTap: () => _showServicePicker(context, ref, settings.defaultService),
),
// Audio Quality
ListTile(
leading: Icon(Icons.high_quality, color: colorScheme.primary),
title: const Text('Audio Quality'),
subtitle: Text(_getQualityName(settings.audioQuality)),
onTap: () => _showQualityPicker(context, ref, settings.audioQuality),
),
// Filename Format
ListTile(
leading: Icon(Icons.text_fields, color: colorScheme.primary),
title: const Text('Filename Format'),
subtitle: Text(settings.filenameFormat),
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
),
// Download Directory
ListTile(
leading: Icon(Icons.folder, color: colorScheme.primary),
title: const Text('Download Directory'),
subtitle: Text(settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory),
onTap: () => _pickDirectory(context, ref),
),
const Divider(),
// Options Section
_buildSectionHeader(context, 'Options', colorScheme),
// Auto Fallback
SwitchListTile(
secondary: Icon(Icons.sync, color: colorScheme.primary),
title: const Text('Auto Fallback'),
subtitle: const Text('Try other services if download fails'),
value: settings.autoFallback,
onChanged: (value) => ref.read(settingsProvider.notifier).setAutoFallback(value),
),
// Embed Lyrics
SwitchListTile(
secondary: Icon(Icons.lyrics, color: colorScheme.primary),
title: const Text('Embed Lyrics'),
subtitle: const Text('Embed synced lyrics into FLAC files'),
value: settings.embedLyrics,
onChanged: (value) => ref.read(settingsProvider.notifier).setEmbedLyrics(value),
),
// Max Quality Cover
SwitchListTile(
secondary: Icon(Icons.image, color: colorScheme.primary),
title: const Text('Max Quality Cover'),
subtitle: const Text('Download highest resolution cover art'),
value: settings.maxQualityCover,
onChanged: (value) => ref.read(settingsProvider.notifier).setMaxQualityCover(value),
),
// Concurrent Downloads
ListTile(
leading: Icon(Icons.download_for_offline, color: colorScheme.primary),
title: const Text('Concurrent Downloads'),
subtitle: Text(settings.concurrentDownloads == 1
? 'Sequential (1 at a time)'
: '${settings.concurrentDownloads} parallel downloads'),
onTap: () => _showConcurrentDownloadsPicker(context, ref, settings.concurrentDownloads),
),
// Check for Updates
SwitchListTile(
secondary: Icon(Icons.system_update, color: colorScheme.primary),
title: const Text('Check for Updates'),
subtitle: const Text('Notify when new version is available'),
value: settings.checkForUpdates,
onChanged: (value) => ref.read(settingsProvider.notifier).setCheckForUpdates(value),
),
const Divider(),
// GitHub & Credits Section
_buildSectionHeader(context, 'GitHub & Credits', colorScheme),
ListTile(
leading: Icon(Icons.code, color: colorScheme.primary),
title: Text('${AppInfo.appName} Mobile'),
subtitle: Text('github.com/${AppInfo.githubRepo}'),
onTap: () => _launchUrl(AppInfo.githubUrl),
),
ListTile(
leading: Icon(Icons.computer, color: colorScheme.primary),
title: Text('Original ${AppInfo.appName} (Desktop)'),
subtitle: Text('github.com/${AppInfo.originalAuthor}/SpotiFLAC'),
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Mobile version maintained by ${AppInfo.mobileAuthor}\nOriginal project by ${AppInfo.originalAuthor}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
const Divider(),
// About
ListTile(
leading: Icon(Icons.info, color: colorScheme.primary),
title: const Text('About'),
subtitle: Text('${AppInfo.appName} v${AppInfo.version}'),
onTap: () => _showAboutDialog(context),
),
// Bottom padding for navigation bar
const SizedBox(height: 16),
],
);
}
void _showAboutDialog(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Image.asset('assets/images/logo.png', width: 40, height: 40, errorBuilder: (_, _, _) => Icon(Icons.music_note, size: 40, color: colorScheme.primary)),
const SizedBox(width: 12),
Text(AppInfo.appName),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildAboutRow('Version', AppInfo.version, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Mobile', AppInfo.mobileAuthor, colorScheme),
const SizedBox(height: 8),
_buildAboutRow('Original', AppInfo.originalAuthor, colorScheme),
const SizedBox(height: 16),
Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
],
),
);
}
Widget _buildAboutRow(String label, String value, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(color: colorScheme.onSurfaceVariant)),
Text(value, style: const TextStyle(fontWeight: FontWeight.w500)),
],
);
}
Widget _buildSectionHeader(BuildContext context, String title, ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
String _getThemeModeName(ThemeMode mode) {
switch (mode) {
case ThemeMode.light: return 'Light';
case ThemeMode.dark: return 'Dark';
case ThemeMode.system: return 'System';
}
}
String _getServiceName(String service) {
switch (service) {
case 'tidal': return 'Tidal';
case 'qobuz': return 'Qobuz';
case 'amazon': return 'Amazon Music';
default: return service;
}
}
String _getQualityName(String quality) {
switch (quality) {
case 'LOSSLESS': return 'FLAC (16-bit / 44.1kHz)';
case 'HI_RES': return 'Hi-Res FLAC (24-bit / 96kHz)';
case 'HI_RES_LOSSLESS': return 'Hi-Res FLAC (24-bit / 192kHz)';
default: return quality;
}
}
void _showThemeModePicker(BuildContext context, WidgetRef ref, ThemeMode current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Theme Mode'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildThemeModeOption(context, ref, ThemeMode.system, 'System', Icons.brightness_auto, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.light, 'Light', Icons.light_mode, current, colorScheme),
_buildThemeModeOption(context, ref, ThemeMode.dark, 'Dark', Icons.dark_mode, current, colorScheme),
],
),
),
);
}
Widget _buildThemeModeOption(BuildContext context, WidgetRef ref, ThemeMode mode, String label, IconData icon, ThemeMode current, ColorScheme colorScheme) {
final isSelected = mode == current;
return ListTile(
leading: Icon(icon, color: isSelected ? colorScheme.primary : null),
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(themeProvider.notifier).setThemeMode(mode);
Navigator.pop(context);
},
);
}
void _showColorPicker(BuildContext context, WidgetRef ref, int currentColor) {
final colors = [
const Color(0xFF1DB954), const Color(0xFF6750A4), const Color(0xFF0061A4),
const Color(0xFF006E1C), const Color(0xFFBA1A1A), const Color(0xFF984061),
const Color(0xFF7D5260), const Color(0xFF006874), const Color(0xFFFF6F00),
];
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choose Accent Color'),
content: Wrap(
spacing: 12,
runSpacing: 12,
children: colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () {
ref.read(themeProvider.notifier).setSeedColor(color);
Navigator.pop(context);
},
child: Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: Theme.of(context).colorScheme.onSurface, width: 3) : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white) : null,
),
);
}).toList(),
),
),
);
}
void _showServicePicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Service'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildServiceOption(context, ref, 'tidal', 'Tidal', current, colorScheme),
_buildServiceOption(context, ref, 'qobuz', 'Qobuz', current, colorScheme),
_buildServiceOption(context, ref, 'amazon', 'Amazon Music', current, colorScheme),
],
),
),
);
}
Widget _buildServiceOption(BuildContext context, WidgetRef ref, String value, String label, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(label),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setDefaultService(value);
Navigator.pop(context);
},
);
}
void _showQualityPicker(BuildContext context, WidgetRef ref, String current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Select Quality'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Disclaimer
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_buildQualityOption(context, ref, 'LOSSLESS', 'FLAC (Lossless)', '16-bit / 44.1kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES', 'Hi-Res FLAC', '24-bit / up to 96kHz', current, colorScheme),
_buildQualityOption(context, ref, 'HI_RES_LOSSLESS', 'Hi-Res FLAC Max', '24-bit / up to 192kHz', current, colorScheme),
],
),
),
);
}
Widget _buildQualityOption(BuildContext context, WidgetRef ref, String value, String title, String subtitle, String current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setAudioQuality(value);
Navigator.pop(context);
},
);
}
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
final controller = TextEditingController(text: current);
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filename Format'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}')),
const SizedBox(height: 16),
Text('Available placeholders:', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Text('{title}, {artist}, {album}, {track}, {year}, {disc}', style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
FilledButton(
onPressed: () {
ref.read(settingsProvider.notifier).setFilenameFormat(controller.text);
Navigator.pop(context);
},
child: const Text('Save'),
),
],
),
);
}
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showConcurrentDownloadsPicker(BuildContext context, WidgetRef ref, int current) {
final colorScheme = Theme.of(context).colorScheme;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Concurrent Downloads'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildConcurrentOption(context, ref, 1, 'Sequential', 'Download one at a time (recommended)', current, colorScheme),
_buildConcurrentOption(context, ref, 2, '2 Parallel', 'Download 2 tracks simultaneously', current, colorScheme),
_buildConcurrentOption(context, ref, 3, '3 Parallel', 'Download 3 tracks simultaneously', current, colorScheme),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 16, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
'Parallel downloads may trigger rate limiting from streaming services.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
),
),
);
}
Widget _buildConcurrentOption(BuildContext context, WidgetRef ref, int value, String title, String subtitle, int current, ColorScheme colorScheme) {
final isSelected = value == current;
return ListTile(
title: Text(title),
subtitle: Text(subtitle),
trailing: isSelected ? Icon(Icons.check, color: colorScheme.primary) : null,
onTap: () {
ref.read(settingsProvider.notifier).setConcurrentDownloads(value);
Navigator.pop(context);
},
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key});
@@ -44,7 +45,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Widget build(BuildContext context) {
final state = ref.watch(storeProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: RefreshIndicator(
File diff suppressed because it is too large Load Diff
+768
View File
@@ -0,0 +1,768 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
class TutorialScreen extends ConsumerStatefulWidget {
const TutorialScreen({super.key});
@override
ConsumerState<TutorialScreen> createState() => _TutorialScreenState();
}
class _TutorialScreenState extends ConsumerState<TutorialScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
static const int _totalPages = 6;
double _responsiveScale({
required BuildContext context,
double min = 0.82,
double max = 1.08,
double baseShortestSide = 390,
}) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = shortestSide / baseShortestSide;
if (scale < min) return min;
if (scale > max) return max;
return scale;
}
double _effectiveTextScale(BuildContext context) {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _nextPage() {
if (_currentPage < _totalPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutQuart,
);
} else {
_completeTutorial();
}
}
void _prevPage() {
_pageController.previousPage(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutQuart,
);
}
void _completeTutorial() {
ref.read(settingsProvider.notifier).setTutorialComplete();
context.go('/');
}
void _skipTutorial() {
ref.read(settingsProvider.notifier).setTutorialComplete();
context.go('/');
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final l10n = context.l10n;
final isLastPage = _currentPage == _totalPages - 1;
final scale = _responsiveScale(context: context, min: 0.86, max: 1.05);
final textScale = _effectiveTextScale(context);
final topBarPaddingH = 24 * scale;
final topBarPaddingV = 16 * scale;
final pageIndicatorHeight = 8 * scale;
final pageIndicatorWidth = 8 * scale;
final activeIndicatorWidth = 32 * scale;
final bottomGap = (32 * scale) + ((textScale - 1) * 8);
final actionButtonHeight = (56 * scale) + ((textScale - 1) * 6);
return Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
// Top Navigation Bar
Padding(
padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
vertical: topBarPaddingV,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _currentPage > 0 ? 1.0 : 0.0,
child: IconButton.filledTonal(
onPressed: _currentPage > 0 ? _prevPage : null,
icon: const Icon(Icons.arrow_back),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
),
// Skip button
TextButton(
onPressed: _skipTutorial,
style: TextButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
child: Text(
l10n.setupSkip,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
),
// Main Content Area
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (page) => setState(() => _currentPage = page),
children: [
_TutorialPage(
index: 0,
currentIndex: _currentPage,
icon: Icons.waving_hand_rounded,
iconColor: Colors.amber,
title: l10n.tutorialWelcomeTitle,
description: l10n.tutorialWelcomeDesc,
content: _buildFeatureList(context, [
(Icons.music_note_rounded, l10n.tutorialWelcomeTip1),
(Icons.high_quality_rounded, l10n.tutorialWelcomeTip2),
(Icons.download_rounded, l10n.tutorialWelcomeTip3),
]),
),
_TutorialPage(
index: 1,
currentIndex: _currentPage,
icon: Icons.search_rounded,
title: l10n.tutorialSearchTitle,
description: l10n.tutorialSearchDesc,
content: const _InteractiveSearchExample(),
),
_TutorialPage(
index: 2,
currentIndex: _currentPage,
icon: Icons.download_rounded,
title: l10n.tutorialDownloadTitle,
description: l10n.tutorialDownloadDesc,
content: const _InteractiveDownloadExample(),
),
_TutorialPage(
index: 3,
currentIndex: _currentPage,
icon: Icons.library_music_rounded,
title: l10n.tutorialLibraryTitle,
description: l10n.tutorialLibraryDesc,
content: _buildFeatureList(context, [
(Icons.offline_pin_rounded, l10n.tutorialLibraryTip1),
(Icons.play_circle_fill, l10n.tutorialLibraryTip2),
(Icons.grid_view_rounded, l10n.tutorialLibraryTip3),
]),
),
_TutorialPage(
index: 4,
currentIndex: _currentPage,
icon: Icons.extension_rounded,
title: l10n.tutorialExtensionsTitle,
description: l10n.tutorialExtensionsDesc,
content: _buildFeatureList(context, [
(Icons.storefront_rounded, l10n.tutorialExtensionsTip1),
(
Icons.add_circle_outline_rounded,
l10n.tutorialExtensionsTip2,
),
(Icons.lyrics_rounded, l10n.tutorialExtensionsTip3),
]),
),
_TutorialPage(
index: 5,
currentIndex: _currentPage,
icon: Icons.settings_rounded,
title: l10n.tutorialSettingsTitle,
description: l10n.tutorialSettingsDesc,
content: Column(
children: [
_buildFeatureList(context, [
(
Icons.folder_open_rounded,
l10n.tutorialSettingsTip1,
),
(Icons.tune_rounded, l10n.tutorialSettingsTip2),
(Icons.palette_rounded, l10n.tutorialSettingsTip3),
]),
const SizedBox(height: 24),
_AnimatedReadyCard(text: l10n.tutorialReadyMessage),
],
),
),
],
),
),
// Bottom Control Area
Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Expressive Page Indicators
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_totalPages, (index) {
final isActive = _currentPage == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack,
margin: EdgeInsets.symmetric(horizontal: 4 * scale),
height: pageIndicatorHeight,
width: isActive
? activeIndicatorWidth
: pageIndicatorWidth,
decoration: BoxDecoration(
color: isActive
? colorScheme.primary
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
);
}),
),
SizedBox(height: bottomGap),
// Action Button
SizedBox(
width: double.infinity,
height: actionButtonHeight,
child: FilledButton(
onPressed: _nextPage,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
child: Text(
isLastPage ? l10n.setupGetStarted : l10n.setupNext,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildFeatureList(
BuildContext context,
List<(IconData, String)> features,
) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: features.asMap().entries.map((entry) {
final index = entry.key;
final feature = entry.value;
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 600 + (index * 200)),
curve: Curves.easeOutQuart,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: Opacity(
opacity: value,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
feature.$1,
size: 24,
color: colorScheme.primary,
),
),
const SizedBox(width: 20),
Expanded(
child: Text(
feature.$2,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
},
);
}).toList(),
);
}
}
class _AnimatedReadyCard extends StatelessWidget {
final String text;
const _AnimatedReadyCard({required this.text});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(28),
),
child: Row(
children: [
Icon(
Icons.lightbulb_rounded,
color: colorScheme.onPrimaryContainer,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Text(
text,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: colorScheme.onPrimaryContainer,
),
),
),
],
),
);
}
}
class _InteractiveSearchExample extends StatefulWidget {
const _InteractiveSearchExample();
@override
State<_InteractiveSearchExample> createState() =>
_InteractiveSearchExampleState();
}
class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
final TextEditingController _controller = TextEditingController();
bool _showResult = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Search Input
TextField(
controller: _controller,
onChanged: (value) {
setState(() {
_showResult = value.isNotEmpty;
});
},
style: TextStyle(color: colorScheme.onSurface, fontSize: 16),
decoration: InputDecoration(
hintText: 'Paste or search...',
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
prefixIcon: Icon(Icons.search, color: colorScheme.primary),
filled: true,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
),
// Result Placeholder
AnimatedSize(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutBack,
child: _showResult
? Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.music_note_rounded,
color: colorScheme.onPrimaryContainer,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface.withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(7),
),
),
const SizedBox(height: 8),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 12),
Icon(
Icons.download_rounded,
color: colorScheme.primary,
),
],
),
),
)
: const SizedBox.shrink(),
),
],
),
);
}
}
class _InteractiveDownloadExample extends StatefulWidget {
const _InteractiveDownloadExample();
@override
State<_InteractiveDownloadExample> createState() =>
_InteractiveDownloadExampleState();
}
class _InteractiveDownloadExampleState
extends State<_InteractiveDownloadExample> {
bool _isDownloading = false;
double _progress = 0.0;
bool _isCompleted = false;
void _startDownload() async {
if (_isDownloading || _isCompleted) return;
setState(() {
_isDownloading = true;
_progress = 0.0;
});
for (int i = 0; i <= 100; i += 5) {
if (!mounted) return;
await Future.delayed(const Duration(milliseconds: 50));
setState(() => _progress = i / 100);
}
setState(() {
_isDownloading = false;
_isCompleted = true;
});
// Reset after a delay
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {
_isCompleted = false;
_progress = 0.0;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Row(
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: (cardWidth * 0.35).clamp(100.0, 160.0),
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(7),
),
),
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,
),
),
),
],
),
);
},
);
}
}
class _TutorialPage extends StatelessWidget {
final int index;
final int currentIndex;
final IconData icon;
final String title;
final String description;
final Widget content;
final Color? iconColor;
const _TutorialPage({
required this.index,
required this.currentIndex,
required this.icon,
required this.title,
required this.description,
required this.content,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final scale = (shortestSide / 390).clamp(0.86, 1.05);
final topGap = (24 * scale).clamp(16.0, 24.0);
final iconPadding = (24 * scale).clamp(18.0, 24.0);
final iconSize = (56 * scale).clamp(44.0, 56.0);
final iconTextGap = (48 * scale).clamp(28.0, 48.0);
final descriptionGap = (20 * scale).clamp(12.0, 20.0);
final contentGap = (56 * scale) + ((textScale - 1) * 10);
final bottomGap = (32 * scale).clamp(20.0, 32.0);
// Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable
// But for now, let's use entrance animations based on currentIndex == index
final isActive = currentIndex == index;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
physics: const BouncingScrollPhysics(),
child: Column(
children: [
SizedBox(height: topGap),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack,
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration(
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: iconSize,
color: iconColor ?? colorScheme.primary,
),
),
SizedBox(height: iconTextGap),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
curve: Curves.easeOut,
child: Text(
title,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
letterSpacing: -0.5,
),
textAlign: TextAlign.center,
),
),
SizedBox(height: descriptionGap),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
curve: Curves.easeOut,
child: Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
),
textAlign: TextAlign.center,
),
),
SizedBox(height: contentGap),
content, // The content itself now handles its own internal animations
SizedBox(height: bottomGap),
],
),
);
}
}
+114 -83
View File
@@ -21,7 +21,7 @@ class CsvImportService {
final file = File(result.files.single.path!);
final content = await file.readAsString();
final tracks = _parseCsv(content);
if (tracks.isNotEmpty) {
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
}
@@ -39,43 +39,50 @@ class CsvImportService {
}) async {
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
final enrichedTracks = <Track>[];
for (int i = 0; i < tracks.length; i++) {
final track = tracks[i];
onProgress?.call(i + 1, tracks.length);
if (track.coverUrl == null || track.duration == 0) {
Map<String, dynamic>? trackData;
if (track.isrc != null && track.isrc!.isNotEmpty) {
try {
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
_log.d('ISRC enrichment success for ${track.name}');
} 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) {
try {
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')) {
final tracksList = searchResult['tracks'] as List<dynamic>?;
if (tracksList != null && tracksList.isNotEmpty) {
for (final result in tracksList) {
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();
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
if (resultName.contains(trackNameLower) ||
trackNameLower.contains(resultName)) {
trackData = resultMap;
_log.d('Text search match for ${track.name}: $resultName');
break;
}
}
if (trackData == null && tracksList.isNotEmpty) {
trackData = tracksList.first as Map<String, dynamic>;
_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');
}
}
if (trackData != null) {
final coverUrl = trackData['images'] as String?;
final durationMs = trackData['duration_ms'] as int? ?? 0;
final deezerIdRaw = trackData['spotify_id'] as String?;
enrichedTracks.add(Track(
id: deezerIdRaw ?? track.id,
name: trackData['name'] as String? ?? track.name,
artistName: trackData['artists'] as String? ?? track.artistName,
albumName: trackData['album_name'] as String? ?? track.albumName,
albumArtist: trackData['album_artist'] as String?,
coverUrl: coverUrl ?? track.coverUrl,
isrc: trackData['isrc'] as String? ?? track.isrc,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
trackNumber: trackData['track_number'] as int? ?? track.trackNumber,
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
releaseDate: trackData['release_date'] as String? ?? track.releaseDate,
));
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
enrichedTracks.add(
Track(
id: deezerIdRaw ?? track.id,
name: trackData['name'] as String? ?? track.name,
artistName: trackData['artists'] as String? ?? track.artistName,
albumName: trackData['album_name'] as String? ?? track.albumName,
albumArtist: trackData['album_artist'] as String?,
coverUrl: coverUrl ?? track.coverUrl,
isrc: trackData['isrc'] as String? ?? track.isrc,
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
trackNumber:
trackData['track_number'] as int? ?? track.trackNumber,
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
releaseDate:
trackData['release_date'] as String? ?? track.releaseDate,
),
);
_log.d(
'Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s',
);
if (i < tracks.length - 1) {
await Future.delayed(const Duration(milliseconds: 100));
}
continue;
}
}
enrichedTracks.add(track);
}
_log.i('Enrichment complete: ${enrichedTracks.length} tracks');
return enrichedTracks;
}
@@ -136,8 +149,8 @@ class CsvImportService {
final headers = _parseLine(lines[startIdx]);
final colMap = <String, int>{};
for (int i = 0; i < headers.length; i++) {
String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i;
String h = _cleanValue(headers[i]).toLowerCase();
colMap[h] = i;
}
_log.d('CSV Headers: ${colMap.keys.toList()}');
@@ -147,48 +160,67 @@ class CsvImportService {
if (line.isEmpty) continue;
final values = _parseLine(line);
String? getVal(List<String> keys) {
return _getValue(values, colMap, keys);
}
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? 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:')) {
spotifyId = spotifyId.replaceAll('spotify:track:', '');
}
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
tracks.add(Track(
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
name: trackName ?? 'Unknown Track',
artistName: artistName ?? 'Unknown Artist',
albumName: albumName ?? 'Unknown Album',
isrc: isrc,
duration: 0, // Will be updated by enrichment later
coverUrl: null, // Will be fetched by enrichment
));
if ((trackName != null && trackName.isNotEmpty && artistName != null) ||
(spotifyId != null && spotifyId.isNotEmpty)) {
tracks.add(
Track(
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
name: trackName ?? 'Unknown Track',
artistName: artistName ?? 'Unknown Artist',
albumName: albumName ?? 'Unknown Album',
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');
return tracks;
}
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) {
for (final key in possibleKeys) {
if (colMap.containsKey(key)) {
final index = colMap[key]!;
if (index < values.length) {
return _cleanValue(values[index]);
}
}
static String? _getValue(
List<String> values,
Map<String, int> colMap,
List<String> possibleKeys,
) {
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) {
@@ -201,30 +233,29 @@ class CsvImportService {
}
static List<String> _parseLine(String line) {
final List<String> result = [];
bool inQuote = false;
StringBuffer buffer = StringBuffer();
for (int i=0; i<line.length; i++) {
String char = line[i];
if (char == '"') {
if (i + 1 < line.length && line[i+1] == '"') {
buffer.write('"');
buffer.write('"');
i++; // Skip next quote char loop
buffer.write('"'); // Write 2nd quote
} else {
inQuote = !inQuote;
buffer.write(char);
}
} else if (char == ',' && !inQuote) {
result.add(buffer.toString());
buffer.clear();
} else {
buffer.write(char);
}
}
result.add(buffer.toString());
return result;
final List<String> result = [];
bool inQuote = false;
var buffer = StringBuffer();
for (int i = 0; i < line.length; i++) {
final char = line[i];
if (char == '"') {
if (inQuote && i + 1 < line.length && line[i + 1] == '"') {
buffer.write('"');
i++;
} else {
inQuote = !inQuote;
}
continue;
}
if (char == ',' && !inQuote) {
result.add(buffer.toString());
buffer = StringBuffer();
continue;
}
buffer.write(char);
}
result.add(buffer.toString());
return result;
}
}
+198 -116
View File
@@ -10,12 +10,58 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
class FFmpegService {
static const int _commandLogPreviewLength = 300;
static int _tempEmbedCounter = 0;
static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
final inputFile = File(inputPath);
final dir = inputFile.parent.path;
final filename = inputFile.uri.pathSegments.last;
final dotIndex = filename.lastIndexOf('.');
final baseName = dotIndex > 0 ? filename.substring(0, dotIndex) : filename;
var outputPath = '$dir${Platform.pathSeparator}$baseName$normalizedExt';
if (outputPath == inputPath) {
outputPath =
'$dir${Platform.pathSeparator}${baseName}_converted$normalizedExt';
}
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 {
try {
final session = await FFmpegKit.execute(command);
final returnCode = await session.getReturnCode();
final output = await session.getOutput() ?? '';
return FFmpegResult(
success: ReturnCode.isSuccess(returnCode),
returnCode: returnCode?.getValue() ?? -1,
@@ -28,7 +74,7 @@ class FFmpegService {
}
static Future<String?> convertM4aToFlac(String inputPath) async {
final outputPath = inputPath.replaceAll('.m4a', '.flac');
final outputPath = _buildOutputPath(inputPath, '.flac');
final command =
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
@@ -59,10 +105,10 @@ class FFmpegService {
bitrateValue = '${parts[1]}k';
}
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = inputPath.replaceAll('.m4a', extension);
final outputPath = _buildOutputPath(inputPath, extension);
String command;
if (format == 'opus') {
command =
@@ -92,7 +138,7 @@ class FFmpegService {
String bitrate = '320k',
bool deleteOriginal = true,
}) async {
final outputPath = inputPath.replaceAll('.flac', '.mp3');
final outputPath = _buildOutputPath(inputPath, '.mp3');
final command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
@@ -117,7 +163,7 @@ class FFmpegService {
String bitrate = '128k',
bool deleteOriginal = true,
}) async {
final outputPath = inputPath.replaceAll('.flac', '.opus');
final outputPath = _buildOutputPath(inputPath, '.opus');
final command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a -map_metadata 0 "$outputPath" -y';
@@ -150,15 +196,27 @@ class FFmpegService {
bitrateValue = '${parts[1]}k';
}
}
switch (format.toLowerCase()) {
case 'opus':
final opusBitrate = bitrate?.startsWith('opus_') == true ? bitrateValue : '128k';
return convertFlacToOpus(inputPath, bitrate: opusBitrate, deleteOriginal: deleteOriginal);
final opusBitrate = bitrate?.startsWith('opus_') == true
? bitrateValue
: '128k';
return convertFlacToOpus(
inputPath,
bitrate: opusBitrate,
deleteOriginal: deleteOriginal,
);
case 'mp3':
default:
final mp3Bitrate = bitrate?.startsWith('mp3_') == true ? bitrateValue : '320k';
return convertFlacToMp3(inputPath, bitrate: mp3Bitrate, deleteOriginal: deleteOriginal);
final mp3Bitrate = bitrate?.startsWith('mp3_') == true
? bitrateValue
: '320k';
return convertFlacToMp3(
inputPath,
bitrate: mp3Bitrate,
deleteOriginal: deleteOriginal,
);
}
}
@@ -168,8 +226,10 @@ class FFmpegService {
String bitrate = '256k',
}) async {
final dir = File(inputPath).parent.path;
final baseName =
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final baseName = inputPath
.split(Platform.pathSeparator)
.last
.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}M4A';
await Directory(outputDir).create(recursive: true);
@@ -218,18 +278,17 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v copy ');
@@ -237,20 +296,20 @@ class FFmpegService {
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg command: $command');
_log.d('Executing FFmpeg command: ${_previewCommandForLog(command)}');
final result = await _execute(command);
@@ -258,20 +317,19 @@ class FFmpegService {
try {
final tempFile = File(tempOutput);
final originalFile = File(flacPath);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(flacPath);
await tempFile.delete();
return flacPath;
} else {
_log.e('Temp output file not found: $tempOutput');
return null;
}
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(flacPath);
await tempFile.delete();
return flacPath;
} else {
_log.e('Temp output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace file after metadata embed: $e');
return null;
@@ -297,18 +355,18 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v:0 copy ');
@@ -316,9 +374,9 @@ class FFmpegService {
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
@@ -326,11 +384,13 @@ class FFmpegService {
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
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);
@@ -338,21 +398,20 @@ class FFmpegService {
try {
final tempFile = File(tempOutput);
final originalFile = File(mp3Path);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(mp3Path);
await tempFile.delete();
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} else {
_log.e('Temp MP3 output file not found: $tempOutput');
return null;
}
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(mp3Path);
await tempFile.delete();
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} else {
_log.e('Temp MP3 output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
@@ -378,28 +437,31 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$opusPath" ');
cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
cmdBuffer.write('-map_metadata:s:a -1 ');
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
if (coverPath != null) {
try {
final pictureBlock = await _createMetadataBlockPicture(coverPath);
if (pictureBlock != null) {
final escapedBlock = pictureBlock.replaceAll('"', '\\"');
cmdBuffer.write('-metadata METADATA_BLOCK_PICTURE="$escapedBlock" ');
_log.d('Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)');
_log.d(
'Created METADATA_BLOCK_PICTURE for Opus (${pictureBlock.length} chars)',
);
} else {
_log.w('Failed to create METADATA_BLOCK_PICTURE, skipping cover');
}
@@ -407,9 +469,9 @@ class FFmpegService {
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
}
}
cmdBuffer.write('"$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg Opus embed command');
@@ -419,21 +481,20 @@ class FFmpegService {
try {
final tempFile = File(tempOutput);
final originalFile = File(opusPath);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(opusPath);
await tempFile.delete();
_log.d('Opus metadata embedded successfully');
return opusPath;
} else {
_log.e('Temp Opus output file not found: $tempOutput');
return null;
}
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(opusPath);
await tempFile.delete();
_log.d('Opus metadata embedded successfully');
return opusPath;
} else {
_log.e('Temp Opus output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace Opus file after metadata embed: $e');
return null;
@@ -460,81 +521,94 @@ class FFmpegService {
_log.e('Cover image not found: $imagePath');
return null;
}
final imageData = await file.readAsBytes();
String mimeType;
if (imagePath.toLowerCase().endsWith('.png')) {
mimeType = 'image/png';
} else if (imagePath.toLowerCase().endsWith('.jpg') ||
imagePath.toLowerCase().endsWith('.jpeg')) {
} else if (imagePath.toLowerCase().endsWith('.jpg') ||
imagePath.toLowerCase().endsWith('.jpeg')) {
mimeType = 'image/jpeg';
} else {
if (imageData.length >= 8 &&
imageData[0] == 0x89 && imageData[1] == 0x50 &&
imageData[2] == 0x4E && imageData[3] == 0x47) {
if (imageData.length >= 8 &&
imageData[0] == 0x89 &&
imageData[1] == 0x50 &&
imageData[2] == 0x4E &&
imageData[3] == 0x47) {
mimeType = 'image/png';
} else if (imageData.length >= 2 &&
imageData[0] == 0xFF && imageData[1] == 0xD8) {
} else if (imageData.length >= 2 &&
imageData[0] == 0xFF &&
imageData[1] == 0xD8) {
mimeType = 'image/jpeg';
} else {
mimeType = 'image/jpeg';
}
}
final mimeBytes = utf8.encode(mimeType);
const description = '';
final descBytes = utf8.encode(description);
final blockSize = 4 + 4 + mimeBytes.length + 4 + descBytes.length +
4 + 4 + 4 + 4 + 4 + imageData.length;
final blockSize =
4 +
4 +
mimeBytes.length +
4 +
descBytes.length +
4 +
4 +
4 +
4 +
4 +
imageData.length;
final buffer = ByteData(blockSize);
var offset = 0;
buffer.setUint32(offset, 3, Endian.big);
offset += 4;
buffer.setUint32(offset, mimeBytes.length, Endian.big);
offset += 4;
final blockBytes = Uint8List(blockSize);
blockBytes.setRange(0, offset, buffer.buffer.asUint8List());
blockBytes.setRange(offset, offset + mimeBytes.length, mimeBytes);
offset += mimeBytes.length;
final tempBuffer = ByteData(4);
tempBuffer.setUint32(0, descBytes.length, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
blockBytes.setRange(offset, offset + descBytes.length, descBytes);
offset += descBytes.length;
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
tempBuffer.setUint32(0, 0, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
tempBuffer.setUint32(0, imageData.length, Endian.big);
blockBytes.setRange(offset, offset + 4, tempBuffer.buffer.asUint8List());
offset += 4;
blockBytes.setRange(offset, offset + imageData.length, imageData);
final base64String = base64Encode(blockBytes);
return base64String;
} catch (e) {
_log.e('Error creating METADATA_BLOCK_PICTURE: $e');
@@ -542,13 +616,15 @@ class FFmpegService {
}
}
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
static Map<String, String> _convertToId3Tags(
Map<String, String> vorbisMetadata,
) {
final id3Map = <String, String>{};
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final value = entry.value;
switch (key) {
case 'TITLE':
id3Map['title'] = value;
@@ -581,11 +657,17 @@ class FFmpegService {
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
case 'COMPOSER':
id3Map['composer'] = value;
break;
case 'COMMENT':
id3Map['comment'] = value;
break;
default:
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
}
+62 -3
View File
@@ -34,7 +34,7 @@ class HistoryDatabase {
return await openDatabase(
path,
version: 1,
version: 3,
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -52,6 +52,11 @@ class HistoryDatabase {
album_artist TEXT,
cover_url TEXT,
file_path TEXT NOT NULL,
storage_mode TEXT,
download_tree_uri TEXT,
saf_relative_dir TEXT,
saf_file_name TEXT,
saf_repaired INTEGER,
service TEXT NOT NULL,
downloaded_at TEXT NOT NULL,
isrc TEXT,
@@ -80,7 +85,15 @@ class HistoryDatabase {
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion');
// Future migrations go here
if (oldVersion < 2) {
await db.execute('ALTER TABLE history ADD COLUMN storage_mode TEXT');
await db.execute('ALTER TABLE history ADD COLUMN download_tree_uri TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_relative_dir TEXT');
await db.execute('ALTER TABLE history ADD COLUMN saf_file_name TEXT');
}
if (oldVersion < 3) {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
}
}
// ==================== iOS Path Normalization ====================
@@ -244,6 +257,11 @@ class HistoryDatabase {
'album_artist': json['albumArtist'],
'cover_url': json['coverUrl'],
'file_path': json['filePath'],
'storage_mode': json['storageMode'],
'download_tree_uri': json['downloadTreeUri'],
'saf_relative_dir': json['safRelativeDir'],
'saf_file_name': json['safFileName'],
'saf_repaired': json['safRepaired'] == true ? 1 : 0,
'service': json['service'],
'downloaded_at': json['downloadedAt'],
'isrc': json['isrc'],
@@ -272,6 +290,11 @@ class HistoryDatabase {
'albumArtist': row['album_artist'],
'coverUrl': row['cover_url'],
'filePath': _normalizeIosPath(row['file_path'] as String?),
'storageMode': row['storage_mode'],
'downloadTreeUri': row['download_tree_uri'],
'safRelativeDir': row['saf_relative_dir'],
'safFileName': row['saf_file_name'],
'safRepaired': row['saf_repaired'] == 1 || row['saf_repaired'] == true,
'service': row['service'],
'downloadedAt': row['downloaded_at'],
'isrc': row['isrc'],
@@ -427,10 +450,46 @@ class HistoryDatabase {
return null;
}
/// Close database
/// Close database
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
/// Get all file paths from download history
/// Used to exclude downloaded files from local library scan
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""'
);
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Get all entries with file paths for orphan detection
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
final db = await database;
final rows = await db.rawQuery('''
SELECT id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name
FROM history
WHERE file_path IS NOT NULL AND file_path != ""
''');
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0;
final db = await database;
final placeholders = List.filled(ids.length, '?').join(',');
final count = await db.rawDelete(
'DELETE FROM history WHERE id IN ($placeholders)',
ids,
);
_log.i('Deleted $count orphaned entries');
return count;
}
}
+70 -3
View File
@@ -1,8 +1,8 @@
import 'dart:io';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/file_access.dart';
final _log = AppLogger('LibraryDatabase');
@@ -15,6 +15,7 @@ class LocalLibraryItem {
final String filePath;
final String? coverPath;
final DateTime scannedAt;
final int? fileModTime;
final String? isrc;
final int? trackNumber;
final int? discNumber;
@@ -34,6 +35,7 @@ class LocalLibraryItem {
required this.filePath,
this.coverPath,
required this.scannedAt,
this.fileModTime,
this.isrc,
this.trackNumber,
this.discNumber,
@@ -54,6 +56,7 @@ class LocalLibraryItem {
'filePath': filePath,
'coverPath': coverPath,
'scannedAt': scannedAt.toIso8601String(),
'fileModTime': fileModTime,
'isrc': isrc,
'trackNumber': trackNumber,
'discNumber': discNumber,
@@ -75,6 +78,7 @@ class LocalLibraryItem {
filePath: json['filePath'] as String,
coverPath: json['coverPath'] as String?,
scannedAt: DateTime.parse(json['scannedAt'] as String),
fileModTime: (json['fileModTime'] as num?)?.toInt(),
isrc: json['isrc'] as String?,
trackNumber: json['trackNumber'] as int?,
discNumber: json['discNumber'] as int?,
@@ -111,7 +115,7 @@ class LibraryDatabase {
return await openDatabase(
path,
version: 2, // Bumped version for cover_path migration
version: 3, // Bumped version for file_mod_time migration
onCreate: _createDB,
onUpgrade: _upgradeDB,
);
@@ -130,6 +134,7 @@ class LibraryDatabase {
file_path TEXT NOT NULL UNIQUE,
cover_path TEXT,
scanned_at TEXT NOT NULL,
file_mod_time INTEGER,
isrc TEXT,
track_number INTEGER,
disc_number INTEGER,
@@ -158,6 +163,12 @@ class LibraryDatabase {
await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT');
_log.i('Added cover_path column');
}
if (oldVersion < 3) {
// Add file_mod_time column for incremental scanning
await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER');
_log.i('Added file_mod_time column for incremental scanning');
}
}
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
@@ -170,6 +181,7 @@ class LibraryDatabase {
'file_path': json['filePath'],
'cover_path': json['coverPath'],
'scanned_at': json['scannedAt'],
'file_mod_time': json['fileModTime'],
'isrc': json['isrc'],
'track_number': json['trackNumber'],
'disc_number': json['discNumber'],
@@ -192,6 +204,7 @@ class LibraryDatabase {
'filePath': row['file_path'],
'coverPath': row['cover_path'],
'scannedAt': row['scanned_at'],
'fileModTime': row['file_mod_time'],
'isrc': row['isrc'],
'trackNumber': row['track_number'],
'discNumber': row['disc_number'],
@@ -341,7 +354,7 @@ class LibraryDatabase {
int removed = 0;
for (final row in rows) {
final filePath = row['file_path'] as String;
if (!await File(filePath).exists()) {
if (!await fileExists(filePath)) {
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
removed++;
}
@@ -383,4 +396,58 @@ class LibraryDatabase {
await db.close();
_database = null;
}
/// Get all file paths with their modification times for incremental scanning
/// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds)
Future<Map<String, int>> getFileModTimes() async {
final db = await database;
final rows = await db.rawQuery(
'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library'
);
final result = <String, int>{};
for (final row in rows) {
final path = row['file_path'] as String;
final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0;
result[path] = modTime;
}
return result;
}
/// Update file_mod_time for existing rows using file_path as key.
Future<void> updateFileModTimes(Map<String, int> fileModTimes) async {
if (fileModTimes.isEmpty) return;
final db = await database;
final batch = db.batch();
for (final entry in fileModTimes.entries) {
batch.update(
'library',
{'file_mod_time': entry.value},
where: 'file_path = ?',
whereArgs: [entry.key],
);
}
await batch.commit(noResult: true);
}
/// Get all file paths in the library (for detecting deleted files)
Future<Set<String>> getAllFilePaths() async {
final db = await database;
final rows = await db.rawQuery('SELECT file_path FROM library');
return rows.map((r) => r['file_path'] as String).toSet();
}
/// Delete multiple items by their file paths
Future<int> deleteByPaths(List<String> filePaths) async {
if (filePaths.isEmpty) return 0;
final db = await database;
final placeholders = List.filled(filePaths.length, '?').join(',');
final result = await db.rawDelete(
'DELETE FROM library WHERE file_path IN ($placeholders)',
filePaths,
);
if (result > 0) {
_log.i('Deleted $result items from library');
}
return result;
}
}
+31 -31
View File
@@ -30,7 +30,7 @@ class NotificationService {
iOS: iosSettings,
);
await _notifications.initialize(initSettings);
await _notifications.initialize(settings: initSettings);
if (Platform.isAndroid) {
await _notifications
@@ -90,10 +90,10 @@ class NotificationService {
);
await _notifications.show(
downloadProgressId,
'Downloading $trackName',
'$artistName$percentage%',
details,
id: downloadProgressId,
title: 'Downloading $trackName',
body: '$artistName$percentage%',
notificationDetails: details,
);
}
@@ -133,10 +133,10 @@ class NotificationService {
);
await _notifications.show(
downloadProgressId,
'Finalizing $trackName',
'$artistName • Embedding metadata...',
details,
id: downloadProgressId,
title: 'Finalizing $trackName',
body: '$artistName • Embedding metadata...',
notificationDetails: details,
);
}
@@ -183,10 +183,10 @@ class NotificationService {
);
await _notifications.show(
downloadProgressId,
title,
'$trackName - $artistName',
details,
id: downloadProgressId,
title: title,
body: '$trackName - $artistName',
notificationDetails: details,
);
}
@@ -223,15 +223,15 @@ class NotificationService {
);
await _notifications.show(
downloadProgressId,
title,
'$completedCount tracks downloaded successfully',
details,
id: downloadProgressId,
title: title,
body: '$completedCount tracks downloaded successfully',
notificationDetails: details,
);
}
Future<void> cancelDownloadNotification() async {
await _notifications.cancel(downloadProgressId);
await _notifications.cancel(id: downloadProgressId);
}
Future<void> showUpdateDownloadProgress({
@@ -274,10 +274,10 @@ class NotificationService {
);
await _notifications.show(
updateDownloadId,
'Downloading SpotiFLAC v$version',
'$receivedMB / $totalMB MB • $percentage%',
details,
id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version',
body: '$receivedMB / $totalMB MB • $percentage%',
notificationDetails: details,
);
}
@@ -307,10 +307,10 @@ class NotificationService {
);
await _notifications.show(
updateDownloadId,
'Update Ready',
'SpotiFLAC v$version downloaded. Tap to install.',
details,
id: updateDownloadId,
title: 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.',
notificationDetails: details,
);
}
@@ -339,14 +339,14 @@ class NotificationService {
);
await _notifications.show(
updateDownloadId,
'Update Failed',
'Could not download update. Try again later.',
details,
id: updateDownloadId,
title: 'Update Failed',
body: 'Could not download update. Try again later.',
notificationDetails: details,
);
}
Future<void> cancelUpdateNotification() async {
await _notifications.cancel(updateDownloadId);
await _notifications.cancel(id: updateDownloadId);
}
}
-90
View File
@@ -1,90 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
/// Service for extracting dominant colors from images
/// Uses caching to avoid re-extraction and small image size for speed
class PaletteService {
static final PaletteService instance = PaletteService._();
PaletteService._();
final Map<String, Color> _colorCache = {};
/// Extract dominant color from a network image URL
/// Uses small image size and limited colors for speed
Future<Color?> extractDominantColor(String? imageUrl) async {
if (imageUrl == null || imageUrl.isEmpty) return null;
if (!imageUrl.startsWith('http://') && !imageUrl.startsWith('https://')) {
return null;
}
final cached = _colorCache[imageUrl];
if (cached != null) {
return cached;
}
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(imageUrl),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[imageUrl] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService error: $e');
return null;
}
}
Future<Color?> extractDominantColorFromFile(String? filePath) async {
if (filePath == null || filePath.isEmpty) return null;
final cached = _colorCache[filePath];
if (cached != null) {
return cached;
}
try {
final file = File(filePath);
if (!await file.exists()) return null;
final paletteGenerator = await PaletteGenerator.fromImageProvider(
FileImage(file),
size: const Size(64, 64),
maximumColorCount: 8,
);
final color = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
if (color != null) {
_colorCache[filePath] = color;
}
return color;
} catch (e) {
debugPrint('PaletteService file error: $e');
return null;
}
}
void clearCache() {
_colorCache.clear();
}
Color? getCached(String? imageUrl) {
if (imageUrl == null) return null;
return _colorCache[imageUrl];
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
const double kNormalizedHeaderTopPadding = 24.0;
double normalizedHeaderTopPadding(
BuildContext context, {
double max = kNormalizedHeaderTopPadding,
}) {
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
return 0;
}
final topPadding = MediaQuery.paddingOf(context).top;
if (topPadding <= 0) return 0;
return topPadding > max ? max : topPadding;
}
+195
View File
@@ -0,0 +1,195 @@
import 'dart:io';
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/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 {
final int? size;
final DateTime? modified;
const FileAccessStat({this.size, this.modified});
}
bool isContentUri(String? path) {
return path != null && path.startsWith('content://');
}
Future<bool> fileExists(String? path) async {
if (path == null || path.isEmpty) return false;
if (isContentUri(path)) {
return PlatformBridge.safExists(path);
}
return File(path).exists();
}
Future<void> deleteFile(String? path) async {
if (path == null || path.isEmpty) return;
if (isContentUri(path)) {
await PlatformBridge.safDelete(path);
return;
}
try {
await File(path).delete();
} catch (_) {}
}
Future<FileAccessStat?> fileStat(String? path) async {
if (path == null || path.isEmpty) return null;
if (isContentUri(path)) {
final stat = await PlatformBridge.safStat(path);
final exists = stat['exists'] as bool? ?? true;
if (!exists) return null;
return FileAccessStat(
size: stat['size'] as int?,
modified: stat['modified'] != null
? DateTime.fromMillisecondsSinceEpoch(stat['modified'] as int)
: null,
);
}
final stat = await FileStat.stat(path);
if (stat.type == FileSystemEntityType.notFound) return null;
return FileAccessStat(size: stat.size, modified: stat.modified);
}
Future<void> openFile(String path) async {
if (isContentUri(path)) {
await PlatformBridge.openContentUri(path, mimeType: '');
return;
}
final mimeType = audioMimeTypeForPath(path);
final result = await OpenFilex.open(path, type: mimeType);
if (result.type != ResultType.done) {
throw Exception(result.message);
}
}
+89 -49
View File
@@ -7,6 +7,15 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/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 {
final DateTime timestamp;
final String level;
@@ -46,10 +55,11 @@ class LogBuffer extends ChangeNotifier {
LogBuffer._internal();
static const int maxEntries = 500;
static const Duration _goLogPollingInterval = Duration(milliseconds: 800);
final Queue<LogEntry> _entries = Queue<LogEntry>();
Timer? _goLogTimer;
int _lastGoLogIndex = 0;
static bool _loggingEnabled = false;
static bool get loggingEnabled => _loggingEnabled;
static set loggingEnabled(bool value) {
@@ -68,17 +78,33 @@ class LogBuffer extends ChangeNotifier {
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
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) {
_entries.removeFirst();
}
_entries.add(entry);
_entries.add(sanitizedEntry);
notifyListeners();
}
void startGoLogPolling() {
_goLogTimer?.cancel();
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
_goLogTimer = Timer.periodic(_goLogPollingInterval, (_) async {
await _fetchGoLogs();
});
}
@@ -93,13 +119,13 @@ class LogBuffer extends ChangeNotifier {
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
final logs = result['logs'] as List<dynamic>? ?? [];
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
for (final log in logs) {
final timestamp = log['timestamp'] as String? ?? '';
final level = log['level'] as String? ?? 'INFO';
final tag = log['tag'] as String? ?? 'Go';
final message = log['message'] as String? ?? '';
DateTime parsedTime = DateTime.now();
if (timestamp.isNotEmpty) {
try {
@@ -107,25 +133,29 @@ class LogBuffer extends ChangeNotifier {
if (parts.length >= 3) {
final secParts = parts[2].split('.');
parsedTime = DateTime(
parsedTime.year, parsedTime.month, parsedTime.day,
int.parse(parts[0]), int.parse(parts[1]),
parsedTime.year,
parsedTime.month,
parsedTime.day,
int.parse(parts[0]),
int.parse(parts[1]),
int.parse(secParts[0]),
secParts.length > 1 ? int.parse(secParts[1]) : 0,
);
}
} catch (_) {
}
} catch (_) {}
}
add(LogEntry(
timestamp: parsedTime,
level: level,
tag: tag,
message: message,
isFromGo: true,
));
add(
LogEntry(
timestamp: parsedTime,
level: level,
tag: tag,
message: message,
isFromGo: true,
),
);
}
_lastGoLogIndex = nextIndex;
} catch (e) {
if (kDebugMode) {
@@ -156,27 +186,31 @@ class LogBuffer extends ChangeNotifier {
Future<String> exportWithDeviceInfo() async {
final buffer = StringBuffer();
buffer.writeln('=' * 60);
buffer.writeln('SPOTIFLAC LOG EXPORT');
buffer.writeln('=' * 60);
buffer.writeln();
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();
buffer.writeln('--- Device Information ---');
try {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
buffer.writeln('Platform: Android');
buffer.writeln('Device: ${android.manufacturer} ${android.model}');
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('Hardware: ${android.hardware}');
buffer.writeln('Product: ${android.product}');
@@ -196,16 +230,16 @@ class LogBuffer extends ChangeNotifier {
buffer.writeln('Failed to get device info: $e');
}
buffer.writeln();
buffer.writeln('--- Log Summary ---');
buffer.writeln('Total Entries: ${_entries.length}');
int errorCount = 0;
int warnCount = 0;
int infoCount = 0;
int debugCount = 0;
int goCount = 0;
for (final entry in _entries) {
switch (entry.level) {
case 'ERROR':
@@ -224,23 +258,23 @@ class LogBuffer extends ChangeNotifier {
}
if (entry.isFromGo) goCount++;
}
buffer.writeln('Errors: $errorCount');
buffer.writeln('Warnings: $warnCount');
buffer.writeln('Info: $infoCount');
buffer.writeln('Debug: $debugCount');
buffer.writeln('From Go Backend: $goCount');
buffer.writeln();
buffer.writeln('=' * 60);
buffer.writeln('LOG ENTRIES');
buffer.writeln('=' * 60);
buffer.writeln();
for (final entry in _entries) {
buffer.writeln(entry.toString());
}
return buffer.toString();
}
@@ -274,19 +308,21 @@ class BufferedOutput extends LogOutput {
void output(OutputEvent event) {
if (kDebugMode) {
for (final line in event.lines) {
debugPrint(line);
debugPrint(_truncateLogText(line));
}
}
final level = _levelToString(event.level);
final message = event.lines.join('\n');
LogBuffer().add(LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
));
final message = _truncateLogText(event.lines.join('\n'));
LogBuffer().add(
LogEntry(
timestamp: DateTime.now(),
level: level,
tag: tag,
message: message,
),
);
}
String _levelToString(Level level) {
@@ -336,13 +372,15 @@ class AppLogger {
}
void _addToBuffer(String level, String message, {String? error}) {
LogBuffer().add(LogEntry(
timestamp: DateTime.now(),
level: level,
tag: _tag,
message: message,
error: error,
));
LogBuffer().add(
LogEntry(
timestamp: DateTime.now(),
level: level,
tag: _tag,
message: message,
error: error,
),
);
}
void d(String message) {
@@ -373,7 +411,9 @@ class AppLogger {
if (error != null) {
_addToBuffer('ERROR', message, error: error.toString());
if (kDebugMode) {
debugPrint('[$_tag] ERROR: $message | $error');
debugPrint(
'[$_tag] ERROR: ${_truncateLogText(message)} | ${_truncateLogText(error.toString())}',
);
if (stackTrace != null) {
debugPrint(stackTrace.toString());
}
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// A collapsing header widget
/// Title collapses from large to small when scrolling
@@ -19,7 +20,7 @@ class CollapsingHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView(
slivers: [
+331
View File
@@ -0,0 +1,331 @@
import 'package:flutter/material.dart';
/// Custom painted icons for donate platforms
class KofiIcon extends StatelessWidget {
final double size;
final Color color;
const KofiIcon({super.key, this.size = 22, this.color = Colors.white});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _KofiPainter(color),
);
}
}
class _KofiPainter extends CustomPainter {
final Color color;
_KofiPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final s = size.width;
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
// Cup body
final cup = RRect.fromRectAndRadius(
Rect.fromLTWH(s * 0.08, s * 0.28, s * 0.62, s * 0.52),
Radius.circular(s * 0.12),
);
canvas.drawRRect(cup, paint);
// Handle
final handlePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = s * 0.08
..strokeCap = StrokeCap.round;
final handlePath = Path()
..moveTo(s * 0.70, s * 0.40)
..quadraticBezierTo(s * 0.92, s * 0.40, s * 0.92, s * 0.54)
..quadraticBezierTo(s * 0.92, s * 0.68, s * 0.70, s * 0.68);
canvas.drawPath(handlePath, handlePaint);
// Heart on cup
final heartPaint = Paint()
..color = const Color(0xFFFF5E5B)
..style = PaintingStyle.fill;
final hx = s * 0.39;
final hy = s * 0.46;
final hs = s * 0.12;
final heart = Path()
..moveTo(hx, hy + hs * 0.3)
..cubicTo(hx - hs, hy - hs * 0.3, hx - hs * 0.5, hy - hs, hx, hy - hs * 0.4)
..cubicTo(hx + hs * 0.5, hy - hs, hx + hs, hy - hs * 0.3, hx, hy + hs * 0.3)
..close();
canvas.drawPath(heart, heartPaint);
// Steam lines
final steamPaint = Paint()
..color = color.withValues(alpha: 0.6)
..style = PaintingStyle.stroke
..strokeWidth = s * 0.04
..strokeCap = StrokeCap.round;
for (var i = 0; i < 2; i++) {
final sx = s * (0.30 + i * 0.18);
final steam = Path()
..moveTo(sx, s * 0.24)
..quadraticBezierTo(sx - s * 0.04, s * 0.18, sx, s * 0.12);
canvas.drawPath(steam, steamPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class BmacIcon extends StatelessWidget {
final double size;
final Color color;
const BmacIcon({super.key, this.size = 22, this.color = Colors.black87});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _BmacPainter(color),
);
}
}
class _BmacPainter extends CustomPainter {
final Color color;
_BmacPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final s = size.width;
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
// Cup body (slightly tapered)
final cup = Path()
..moveTo(s * 0.15, s * 0.35)
..lineTo(s * 0.20, s * 0.82)
..quadraticBezierTo(s * 0.20, s * 0.90, s * 0.28, s * 0.90)
..lineTo(s * 0.56, s * 0.90)
..quadraticBezierTo(s * 0.64, s * 0.90, s * 0.64, s * 0.82)
..lineTo(s * 0.69, s * 0.35)
..close();
canvas.drawPath(cup, paint);
// Cup rim
final rim = RRect.fromRectAndRadius(
Rect.fromLTWH(s * 0.10, s * 0.30, s * 0.64, s * 0.10),
Radius.circular(s * 0.05),
);
canvas.drawRRect(rim, paint);
// Handle
final handlePaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = s * 0.07
..strokeCap = StrokeCap.round;
final handle = Path()
..moveTo(s * 0.69, s * 0.42)
..quadraticBezierTo(s * 0.90, s * 0.42, s * 0.90, s * 0.56)
..quadraticBezierTo(s * 0.90, s * 0.70, s * 0.69, s * 0.70);
canvas.drawPath(handle, handlePaint);
// Steam
final steamPaint = Paint()
..color = color.withValues(alpha: 0.5)
..style = PaintingStyle.stroke
..strokeWidth = s * 0.04
..strokeCap = StrokeCap.round;
for (var i = 0; i < 3; i++) {
final sx = s * (0.26 + i * 0.14);
final steam = Path()
..moveTo(sx, s * 0.26)
..quadraticBezierTo(sx + s * 0.03, s * 0.18, sx, s * 0.10);
canvas.drawPath(steam, steamPaint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class GitHubIcon extends StatelessWidget {
final double size;
final Color color;
const GitHubIcon({super.key, this.size = 22, this.color = Colors.white});
@override
Widget build(BuildContext context) {
return CustomPaint(
size: Size(size, size),
painter: _GitHubPainter(color),
);
}
}
class _GitHubPainter extends CustomPainter {
final Color color;
_GitHubPainter(this.color);
@override
void paint(Canvas canvas, Size size) {
final s = size.width;
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
// GitHub octocat silhouette (simplified mark)
// Based on the GitHub logo path, scaled to fit
final scale = s / 24.0;
final path = Path();
// Outer circle/head shape
path.moveTo(12 * scale, 0.5 * scale);
path.cubicTo(
5.37 * scale, 0.5 * scale,
0 * scale, 5.87 * scale,
0 * scale, 12.5 * scale,
);
path.cubicTo(
0 * scale, 17.78 * scale,
3.44 * scale, 22.27 * scale,
8.21 * scale, 23.85 * scale,
);
path.cubicTo(
8.81 * scale, 23.96 * scale,
9.02 * scale, 23.59 * scale,
9.02 * scale, 23.27 * scale,
);
path.cubicTo(
9.02 * scale, 22.98 * scale,
9.01 * scale, 22.01 * scale,
9.01 * scale, 21.01 * scale,
);
// Left arm
path.cubicTo(
5.67 * scale, 21.71 * scale,
4.97 * scale, 19.56 * scale,
4.97 * scale, 19.56 * scale,
);
path.cubicTo(
4.42 * scale, 18.22 * scale,
3.63 * scale, 17.85 * scale,
3.63 * scale, 17.85 * scale,
);
path.cubicTo(
2.55 * scale, 17.12 * scale,
3.71 * scale, 17.13 * scale,
3.71 * scale, 17.13 * scale,
);
path.cubicTo(
4.90 * scale, 17.22 * scale,
5.53 * scale, 18.36 * scale,
5.53 * scale, 18.36 * scale,
);
path.cubicTo(
6.58 * scale, 20.05 * scale,
8.36 * scale, 19.53 * scale,
9.05 * scale, 19.22 * scale,
);
path.cubicTo(
9.16 * scale, 18.45 * scale,
9.47 * scale, 17.93 * scale,
9.81 * scale, 17.63 * scale,
);
// Bottom
path.cubicTo(
7.15 * scale, 17.33 * scale,
4.34 * scale, 16.33 * scale,
4.34 * scale, 11.93 * scale,
);
path.cubicTo(
4.34 * scale, 10.68 * scale,
4.81 * scale, 9.66 * scale,
5.55 * scale, 8.86 * scale,
);
path.cubicTo(
5.43 * scale, 8.56 * scale,
5.02 * scale, 7.40 * scale,
5.67 * scale, 5.82 * scale,
);
path.cubicTo(
5.67 * scale, 5.82 * scale,
6.66 * scale, 5.50 * scale,
8.98 * scale, 6.99 * scale,
);
path.cubicTo(
9.94 * scale, 6.72 * scale,
10.98 * scale, 6.59 * scale,
12.0 * scale, 6.58 * scale,
);
path.cubicTo(
13.02 * scale, 6.59 * scale,
14.06 * scale, 6.72 * scale,
15.02 * scale, 6.99 * scale,
);
path.cubicTo(
17.34 * scale, 5.50 * scale,
18.33 * scale, 5.82 * scale,
18.33 * scale, 5.82 * scale,
);
path.cubicTo(
18.98 * scale, 7.40 * scale,
18.57 * scale, 8.56 * scale,
18.45 * scale, 8.86 * scale,
);
path.cubicTo(
19.19 * scale, 9.66 * scale,
19.66 * scale, 10.68 * scale,
19.66 * scale, 11.93 * scale,
);
path.cubicTo(
19.66 * scale, 16.34 * scale,
16.84 * scale, 17.32 * scale,
14.17 * scale, 17.62 * scale,
);
path.cubicTo(
14.59 * scale, 17.99 * scale,
14.97 * scale, 18.70 * scale,
14.97 * scale, 19.80 * scale,
);
path.cubicTo(
14.97 * scale, 21.40 * scale,
14.95 * scale, 22.67 * scale,
14.95 * scale, 23.27 * scale,
);
path.cubicTo(
14.95 * scale, 23.60 * scale,
15.16 * scale, 23.97 * scale,
15.77 * scale, 23.85 * scale,
);
path.cubicTo(
20.55 * scale, 22.26 * scale,
24.0 * scale, 17.78 * scale,
24.0 * scale, 12.5 * scale,
);
path.cubicTo(
24.0 * scale, 5.87 * scale,
18.63 * scale, 0.5 * scale,
12.0 * scale, 0.5 * scale,
);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

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