Compare commits
60 Commits
v3.0.0-beta.1
...
v3.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| be9444c76b | |||
| cedb32904e | |||
| e73f932083 | |||
| 4645d3ac8b | |||
| 1cdf8b7f23 | |||
| 1e18f53e6a | |||
| fc8cfb05d0 | |||
| fc0c0571fe | |||
| e6ca29e199 | |||
| 7413a8a698 | |||
| 205032e094 | |||
| 9c6f438e22 | |||
| 4f2587554a | |||
| 369fdd84bf | |||
| 5c3b668e92 | |||
| 141db45051 | |||
| 8f9bc8f058 | |||
| be372604fe | |||
| 6c25fc6a8d | |||
| 2eef021587 | |||
| 9eac6e6e56 | |||
| e5c310f455 | |||
| d8f73dfa56 | |||
| f128d0caf0 | |||
| aa499ceba2 | |||
| 01306afc2d | |||
| 9a3cd0273b | |||
| ac25683f33 | |||
| 624b2112d8 | |||
| 8bd34dc87e | |||
| 948779bcfc | |||
| a74b3a19f7 | |||
| 931d9fbf61 | |||
| a8c76004db | |||
| 0df4596f79 | |||
| cf549df049 | |||
| bd3783154b | |||
| 6919408905 | |||
| f4c08a5981 | |||
| 7fff55da96 | |||
| 3c4dbd1a80 | |||
| f26af38c1e | |||
| 7c6705c75c | |||
| b193bc0b8f | |||
| 1a90887465 | |||
| 82440affac | |||
| 6d2f75c5dc | |||
| 18bc079632 | |||
| 4091a9c499 | |||
| 9346f2d149 | |||
| 8ab52959e8 | |||
| bad95e99c8 | |||
| dbd7fd70be | |||
| 125d070cfe | |||
| 15acf181d1 | |||
| e049f9b868 | |||
| 6a886c5276 | |||
| 1ec190bfe7 | |||
| 7ca032b3f5 | |||
| 00753ffe86 |
@@ -345,6 +345,8 @@ jobs:
|
||||
CHANGELOG="See CHANGELOG.md for details."
|
||||
else
|
||||
echo "Found changelog content"
|
||||
# Remove trailing --- separator if present (CHANGELOG uses --- between versions)
|
||||
CHANGELOG=$(echo "$CHANGELOG" | sed '/^---$/d')
|
||||
fi
|
||||
|
||||
# Save to file for multiline support
|
||||
|
||||
@@ -53,3 +53,20 @@ ios/.symlinks/
|
||||
ios/Flutter/Flutter.framework/
|
||||
ios/Flutter/Flutter.podspec
|
||||
android/app/libs/gobackend-sources.jar
|
||||
|
||||
# Extension folder
|
||||
extension/
|
||||
|
||||
# Agent instructions
|
||||
AGENTS.md
|
||||
|
||||
# Temp/misc
|
||||
nul
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
hs_err_*.log
|
||||
flutter_*.log
|
||||
|
||||
# Development tools
|
||||
tool/
|
||||
|
||||
@@ -1,5 +1,384 @@
|
||||
# Changelog
|
||||
|
||||
## [3.1.0] - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Recent Access History**: Quick access to recently visited content when tapping the search bar
|
||||
- Shows recently visited artists, albums, playlists, and downloaded tracks
|
||||
- Merged view combining navigation history and download history
|
||||
- Tap to quickly navigate back to previously accessed content
|
||||
- X button to remove individual items from history
|
||||
- "Clear All" button to clear entire history
|
||||
- Persists across app restarts (stored in SharedPreferences)
|
||||
- Max 20 items stored, sorted by most recent
|
||||
- Multi-language support (Artist/Album/Song/Playlist labels localized)
|
||||
|
||||
- **Artist Screen Redesign**
|
||||
- Full-width header image (380px) with gradient overlay
|
||||
- Artist name displayed at bottom of header with text shadow
|
||||
- Monthly listeners count display (formatted with compact notation)
|
||||
- "Popular" section showing top 5 tracks with download status indicators
|
||||
- Dynamic download button states (queued, downloading, completed)
|
||||
- Header image and top tracks fetched from extension metadata
|
||||
- Image alignment set to top-center to show faces properly
|
||||
|
||||
- **Extension Store Update Badge**: Badge indicator on Store tab icon showing number of available updates
|
||||
- Users can see extension updates are available without opening Store tab
|
||||
- Badge shows count of extensions with updates
|
||||
|
||||
- **Extension Compatibility Warning**: Warning badge for extensions requiring newer app version
|
||||
- Extensions with `minAppVersion` higher than current app show warning label
|
||||
- Label displays "Requires vX.X.X+" to encourage users to upgrade
|
||||
- Users can still install the extension (not blocked)
|
||||
|
||||
- **Year in Album Folder Name** ([#50](https://github.com/zarzet/SpotiFLAC-Mobile/issues/50)): New album folder structure options with release year
|
||||
|
||||
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
|
||||
- `[Year] Album Only`: Albums/[2005] X&Y/
|
||||
- Year extracted from release date metadata
|
||||
- Matches desktop SpotiFLAC folder structure
|
||||
|
||||
- **Extension Album/Playlist/Artist Support**: Extensions can now return albums, playlists, and artists in search results
|
||||
|
||||
- Search results now properly separated into Albums, Playlists, Artists, and Songs sections
|
||||
- Albums, playlists, and artists show chevron icon (navigate to detail) instead of download button
|
||||
- Tap album/playlist to view track list and download
|
||||
- Tap artist to view their albums/discography
|
||||
- New `getAlbum()`, `getPlaylist()`, and `getArtist()` extension functions
|
||||
- New `ExtensionAlbumScreen`, `ExtensionPlaylistScreen`, and `ExtensionArtistScreen` for fetching content from extensions
|
||||
- YouTube Music extension updated with album/playlist/artist support
|
||||
|
||||
- **Odesli (song.link) Integration for YouTube Music Extension**
|
||||
- New `enrichTrack()` function to fetch ISRC and external service links
|
||||
- Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz
|
||||
- Enables built-in service fallback for high-quality audio downloads
|
||||
- Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions
|
||||
- **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Search Bar Behavior**: Tapping search bar now immediately moves it to top position
|
||||
- Logo and subtitle hide when search bar is focused
|
||||
- Recent access history appears in the content area below
|
||||
- More space for recent items, not blocked by keyboard
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed search source chips still referencing removed badge props.
|
||||
- Fixed extension artist album metadata to preserve provider IDs and cover URLs for correct navigation.
|
||||
- Fixed extension playlist fetch to populate provider IDs and reject disabled extensions.
|
||||
- Fixed extension collection screens calling setState after dispose during async loads.
|
||||
- Fixed URL handler responses to include provider IDs for extension albums and artists.
|
||||
- Fixed YTMusic extension not extracting album name and duration from search results.
|
||||
- Album name is now extracted from flexColumns/subtitle when linked to album browseId.
|
||||
- Duration is now extracted from fixedColumns/flexColumns in addition to existing sources.
|
||||
- Fixed "Separate Singles" setting not working ([#54](https://github.com/zarzet/SpotiFLAC-Mobile/issues/54)) - singles were going to Albums folder.
|
||||
- Root cause: `albumType` was not being extracted from Deezer API during metadata enrichment.
|
||||
- Deezer track responses now correctly include `album_type` (single/ep/album/compilation).
|
||||
- Track creation now preserves `albumType` and `source` fields throughout download flow.
|
||||
- Fixed PageView overscroll at edges (BouncingScrollPhysics → ClampingScrollPhysics)
|
||||
- Fixed settings item highlight on swipe (highlightColor: Colors.transparent)
|
||||
- Fixed extension duplicate load error (skip silently instead of throwing error)
|
||||
- Fixed keyboard appearing when swiping between tabs (unfocus on page change)
|
||||
- Removed "Free"/"API Key" badges from search source selector
|
||||
- Fixed cancel action briefly resuming downloads in the queue UI after ~1 second.
|
||||
- Fixed cancelled downloads being marked as failed when the backend returns after cancellation.
|
||||
- Fixed cancel triggering provider fallback (cancel now stops the download flow immediately).
|
||||
- Fixed stale ISRC cache returning deleted files after cancel.
|
||||
- Fixed search results mixing extension and built-in artists when using default provider.
|
||||
- Fixed audio files opening with non-music apps by passing audio MIME type on open.
|
||||
- Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags.
|
||||
- Fixed `use_build_context_synchronously` lint warnings in `home_tab.dart`
|
||||
- Fixed `unnecessary_underscores` lint warnings in error widget callbacks
|
||||
- Fixed duplicate artist entries in recent history (recording now only happens in screen's initState)
|
||||
- **Go Backend: Missing `item_type` and `album_type` fields**
|
||||
- Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct
|
||||
- Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response
|
||||
- Fixed `HandleURLWithExtensionJSON` - now includes `item_type` and `album_type` for tracks
|
||||
- Fixed `GetAlbumWithExtensionJSON` - now includes `item_type` and `album_type` for album tracks
|
||||
- Fixed `GetPlaylistWithExtensionJSON` - now includes `item_type` and `album_type` for playlist tracks
|
||||
- **Album/Playlist Track Thumbnails**: Tracks inside albums/playlists now use album/playlist cover as fallback when no individual cover exists
|
||||
- **YouTube Music Extension getArtist**: Fixed `getArtist()` function not being registered in extension, causing artist pages to fail with "returned null" error
|
||||
- **Recent Access UI**: Fixed recent access list disappearing when keyboard is dismissed - now stays visible until user presses Back button
|
||||
- **Extension Artist Top Tracks**: Fixed top tracks not appearing when opening artist from extension search results
|
||||
- YT Music extension `getArtist()` now returns `top_tracks` array with up to 10 popular songs
|
||||
- Go backend `GetArtistWithExtensionJSON` now forwards `top_tracks`, `header_image`, and `listeners` to Flutter
|
||||
- `ExtensionArtistScreen` now parses and passes top tracks to `ArtistScreen`
|
||||
- `ArtistScreen` with `extensionId` skips Spotify/Deezer fetch, uses extension data only (fixes "Rate Limited" errors)
|
||||
- **Search Bar Unfocus**: Fixed search bar not unfocusing when tapping outside - now properly dismisses keyboard and unfocus when tapping anywhere outside the search field
|
||||
- **Keyboard Appearing on Settings Navigation**: Fixed keyboard randomly appearing when returning from Settings sub-pages (e.g., Appearance) - now uses `FocusManager.instance.primaryFocus?.unfocus()` for more aggressive unfocus
|
||||
- **Recent Access Artist Navigation**: Fixed opening artist from recent access using wrong screen - now correctly uses `ExtensionArtistScreen` for extension artists (YT Music, Spotify Web) instead of trying to fetch from Spotify API
|
||||
|
||||
### Extensions
|
||||
|
||||
- **YouTube Music Extension**: Updated to v1.5.0
|
||||
- `getArtist()` now returns `top_tracks` array with popular songs
|
||||
- Added `header_image` and `listeners` to artist response
|
||||
- **Spotify Web Extension**: Updated to v1.6.0
|
||||
|
||||
### Localization
|
||||
|
||||
- **Multi-Language Support**: App now supports multiple languages with community contributions via Crowdin
|
||||
- Available languages: English, Indonesian (Bahasa Indonesia)
|
||||
- More languages coming soon with community translations
|
||||
- Contribute translations at [Crowdin](https://crowdin.com/project/spotiflac-mobile)
|
||||
- Added new localization strings for recent access types:
|
||||
- `recentTypeArtist` - "Artist" / "Artis"
|
||||
- `recentTypeAlbum` - "Album" / "Album"
|
||||
- `recentTypeSong` - "Song" / "Lagu"
|
||||
- `recentTypePlaylist` - "Playlist" / "Playlist"
|
||||
- `recentPlaylistInfo` - "Playlist: {name}"
|
||||
- `errorGeneric` - "Error: {message}"
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0] - 2026-01-14
|
||||
|
||||
### Extension System (Major Feature)
|
||||
|
||||
SpotiFLAC 3.0 introduces a powerful extension system that allows third-party integrations for metadata, downloads, and more.
|
||||
|
||||
#### Extension Store
|
||||
|
||||
- Browse and install extensions directly from the app
|
||||
- New "Store" tab in bottom navigation
|
||||
- Browse by category: Metadata, Download, Utility, Lyrics, Integration
|
||||
- Search extensions by name, description, or tags
|
||||
- One-tap install, update, and uninstall
|
||||
- Offline cache for browsing without internet
|
||||
|
||||
#### Spotify Web Extension
|
||||
|
||||
- Available in Extension Store - install and enable in Settings > Extensions
|
||||
- Metadata provider using Spotify's internal web player API
|
||||
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
|
||||
- Useful when official Spotify API is rate-limited or unavailable
|
||||
|
||||
#### Extension Capabilities
|
||||
|
||||
- **Custom Search Providers**
|
||||
- **Custom URL Handlers**
|
||||
- **Custom Thumbnail Ratios**: Square (1:1), Wide (16:9), Portrait (2:3)
|
||||
- **Post-Processing Hooks**: Extensions can process downloaded files
|
||||
- **Quality Options**: Extensions can define custom quality settings
|
||||
|
||||
#### Extension APIs
|
||||
|
||||
- Full HTTP support: GET, POST, PUT, DELETE, PATCH
|
||||
- Persistent cookie jar per extension
|
||||
- Browser-like polyfills: `fetch()`, `atob()`/`btoa()`, `TextEncoder`/`TextDecoder`, `URL`/`URLSearchParams`
|
||||
- Storage API for persistent data
|
||||
- File API for file operations
|
||||
- HMAC-SHA1 utility for cryptographic operations
|
||||
|
||||
#### Security
|
||||
|
||||
- Sandboxed JavaScript runtime (goja)
|
||||
- Permission-based access control
|
||||
- Network domain whitelisting
|
||||
- Improved credential encryption with per-installation random salt
|
||||
|
||||
### Added
|
||||
|
||||
- **Album Folder Structure Setting**: Option to remove artist folder from album path
|
||||
|
||||
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
|
||||
- `Album Only`: `Albums/Album Name/`
|
||||
|
||||
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
|
||||
|
||||
- Based on `album_type` from Spotify/Deezer metadata
|
||||
- Toggle in Settings > Download > Separate Singles Folder
|
||||
|
||||
- **Year in Album Folder Name**: New album folder structure options with release year
|
||||
|
||||
- `Artist / [Year] Album`: Albums/Coldplay/[2005] X&Y/
|
||||
- `[Year] Album Only`: Albums/[2005] X&Y/
|
||||
- Year extracted from release date metadata
|
||||
- Matches desktop SpotiFLAC folder structure
|
||||
|
||||
- **Parallel API Calls**: Download URL fetching now uses parallel requests
|
||||
- Tidal: All 8 APIs requested simultaneously, first success wins
|
||||
- Qobuz: Both APIs requested simultaneously, first success wins
|
||||
- Significantly reduces download URL fetch time
|
||||
|
||||
### UI/UX Improvements
|
||||
|
||||
- **Swipeable History Filters**: History tab now supports swipe gestures between All, Albums, and Singles filters
|
||||
|
||||
- Swipe left/right to switch between filter tabs
|
||||
- Filter chips sync with swipe position
|
||||
- Smooth edge-to-edge transition: swipe past Singles to go to Store, swipe past All to go to Home
|
||||
- Natural gesture feel - drag connects to parent navigation
|
||||
|
||||
- **Improved File Open Intent**: Play button in History now correctly opens music players only
|
||||
- Added proper MIME type (`audio/flac`, `audio/mpeg`, etc.) when opening downloaded files
|
||||
- Prevents system from showing unrelated apps in the "Open with" dialog
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fixed Tab Edge Overscroll**: Home and Settings tabs now stop at edges instead of bouncing into empty space
|
||||
|
||||
- **Fixed Extension Duplicate Load Error**: Extension loading now silently skips already-loaded extensions instead of throwing error
|
||||
|
||||
- **Fixed Settings Item Highlight on Swipe**: Settings items no longer highlight when swiping at page edge
|
||||
|
||||
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
|
||||
|
||||
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from Deezer/Spotify selector in Options
|
||||
|
||||
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
|
||||
|
||||
- Added `PopScope` with `canPop: true` to all settings pages
|
||||
- Changed navigation to use `PageRouteBuilder` with proper slide transition
|
||||
|
||||
- **Bottom Overflow in Folder Organization Dialog**: Fixed overflow in portrait and landscape mode
|
||||
|
||||
- Made dialog scrollable with max height constraint
|
||||
|
||||
- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names
|
||||
|
||||
- "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
|
||||
|
||||
- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
|
||||
|
||||
- "RADWIMPS feat. Toko Miura" now matches when service only shows "Toko Miura"
|
||||
|
||||
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
|
||||
|
||||
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
|
||||
|
||||
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen
|
||||
|
||||
- Duplicate detection prefix now stripped before saving to history
|
||||
|
||||
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array" error
|
||||
|
||||
- Go backend now handles both array and object formats from extensions
|
||||
|
||||
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
||||
|
||||
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||
|
||||
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
||||
|
||||
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
|
||||
|
||||
- Now shows proper message: "Cannot write to folder, check storage permission"
|
||||
|
||||
- **Android 13+ Storage Permission**: Fixed storage permission not working on Android 13+
|
||||
- Now requests both `MANAGE_EXTERNAL_STORAGE` and `READ_MEDIA_AUDIO`
|
||||
|
||||
### Changed
|
||||
|
||||
- **Extension Manifest**: New `file` permission required for file operations
|
||||
```json
|
||||
"permissions": {
|
||||
"network": ["api.example.com"],
|
||||
"storage": true,
|
||||
"file": true
|
||||
}
|
||||
```
|
||||
|
||||
### Technical
|
||||
|
||||
- Go backend: Simplified parallel download result handling in Tidal/Qobuz
|
||||
- Go backend: Removed unused functions and fixed bit shifting warnings
|
||||
- Release workflow: Fixed duplicate `---` separator in release notes
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-beta.2] - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- **Album Folder Structure Setting**: Option to remove artist folder from album path
|
||||
- New setting in Download Settings when "Separate Singles Folder" is enabled
|
||||
- `Artist / Album` (default): `Albums/Artist Name/Album Name/`
|
||||
- `Album Only`: `Albums/Album Name/`
|
||||
- Requested by user who prefers flat album organization
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Back Gesture Freeze on OnePlus/Android 13+**: Fixed app freeze when using back gesture in settings
|
||||
|
||||
- Added `PopScope` with `canPop: true` to all settings pages
|
||||
- Changed navigation to use `PageRouteBuilder` with proper slide transition
|
||||
- Fixes predictive back gesture conflict on devices with gesture navigation
|
||||
- Affected pages: Download, Appearance, Options, Extensions, About, Logs, Extension Detail
|
||||
|
||||
- **Extension Search Result Parsing**: Fixed "cannot unmarshal array into Go value" error
|
||||
|
||||
- Go backend now handles both array and object formats from extensions
|
||||
- Extensions returning `[{track}, {track}]` now work correctly
|
||||
- Extensions returning `{tracks: [...], total: N}` still work as before
|
||||
|
||||
- **Max Resolution Cover Download**: Fixed cover not upgrading to max resolution on mobile
|
||||
|
||||
- Added missing `spotifySize300` constant (300x300 size code)
|
||||
- Mobile now correctly upgrades 300x300 → 640x640 → max resolution (~2000x2000)
|
||||
- Added `_upgradeToMaxQualityCover()` helper in Flutter for M4A conversion path
|
||||
- Go backend `cover.go` now directly replaces URL without HEAD verification
|
||||
|
||||
- **Extension Search Provider Reset**: Fixed search provider not resetting to default when disabled
|
||||
|
||||
- `copyWith` in `AppSettings` couldn't set `searchProvider` to `null`
|
||||
- Added `clearSearchProvider` boolean parameter to properly clear the value
|
||||
- Settings menu now correctly switches back to default provider
|
||||
|
||||
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
|
||||
|
||||
- `_performSearch` now checks if extension is still enabled before calling custom search
|
||||
- Automatically falls back to Deezer/Spotify search if extension was disabled
|
||||
- Clears `searchProvider` setting if extension no longer available
|
||||
|
||||
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
||||
|
||||
- Added `mounted` check after async operation in `_initialize()`
|
||||
- Prevents crash when navigating away from Store tab during initialization
|
||||
|
||||
- **EXISTS: Prefix in File Path**: Fixed "File not found" error in metadata screen after download
|
||||
|
||||
- Duplicate detection was adding `EXISTS:` prefix to file paths
|
||||
- Prefix now stripped before saving to download history
|
||||
- Legacy history items with prefix are handled gracefully
|
||||
|
||||
- **History Error Badge**: Fixed error badge showing on history items even when file exists
|
||||
|
||||
- `queue_tab.dart` now strips `EXISTS:` prefix before checking file existence
|
||||
- File open and delete operations also use cleaned path
|
||||
|
||||
- **Extension Artist URL Handler**: Fixed artist pages showing "0 releases" from extensions
|
||||
|
||||
- Extension `fetchArtist` now returns correct format: `{ type: "artist", artist: { albums } }`
|
||||
- Go backend `HandleURLWithExtensionJSON` now includes albums in artist response
|
||||
- Added `AlbumType` field to `ExtAlbumMetadata` struct
|
||||
|
||||
- **Extension Artist Name in Logs**: Fixed empty artist name in extension track logs
|
||||
|
||||
- Now uses `firstArtist` + `otherArtists` instead of deprecated `artists.items`
|
||||
- Logs correctly show "Fetched track: {title} by {artist}"
|
||||
|
||||
- **Japanese Artist Name Order**: Fixed artist mismatch for Japanese names with different order
|
||||
|
||||
- "Sawano Hiroyuki" vs "Hiroyuki Sawano" now correctly matches
|
||||
- Added `sameWordsUnordered` check to both Tidal and Qobuz artist matching
|
||||
- Handles Japanese name order (family name first) vs Western name order (given name first)
|
||||
|
||||
- **Multi-Artist Matching**: Fixed artist mismatch for collaboration tracks
|
||||
|
||||
- "RADWIMPS feat. Toko Miura" now matches when Qobuz/Tidal only shows "Toko Miura"
|
||||
- Split artists by separators (`, `, `feat.`, `ft.`, `&`, `and`, `x`)
|
||||
- Match if ANY expected artist matches ANY found artist
|
||||
|
||||
- **Cover Download Logging**: Improved cover download logs for debugging
|
||||
- Shows original URL, upgrade steps, and final URL
|
||||
- Displays estimated resolution based on file size
|
||||
- Logs now appear in Settings > Logs via GoLog
|
||||
|
||||
---
|
||||
|
||||
## [3.0.0-beta.1] - 2026-01-13
|
||||
|
||||
### Security
|
||||
@@ -22,6 +401,7 @@
|
||||
### Fixed
|
||||
|
||||
- Extension packages now preserve directory structure (subdirectories supported)
|
||||
- Back gesture freeze in settings pages on Android gesture navigation
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +410,7 @@
|
||||
### Added
|
||||
|
||||
- **Extension Store**: Browse and install extensions directly from the app
|
||||
|
||||
- New "Store" tab in bottom navigation
|
||||
- Browse extensions by category (Metadata, Download, Utility, Lyrics, Integration)
|
||||
- Search extensions by name, description, or tags
|
||||
@@ -38,6 +419,7 @@
|
||||
- Extensions hosted at github.com/zarzet/SpotiFLAC-Extension
|
||||
|
||||
- **Custom URL Handler for Extensions**: Extensions can now register custom URL patterns
|
||||
|
||||
- Handle URLs from YouTube Music, SoundCloud, Bandcamp, etc.
|
||||
- Manifest config: `urlHandler: { enabled: true, patterns: ["music.youtube.com"] }`
|
||||
- Implement `handleUrl(url)` function in extension to parse and return track metadata
|
||||
@@ -45,6 +427,7 @@
|
||||
- Supports share intents and paste from clipboard
|
||||
|
||||
- **Artist URL Handler Support**: Extensions can now return artist data from URL handlers
|
||||
|
||||
- Added `type: "artist"` handling in track_provider.dart
|
||||
- Navigate to artist screen with albums list from extension
|
||||
|
||||
@@ -122,7 +505,7 @@
|
||||
|
||||
- **Full HTTP Method Support**: New shortcut methods for all common HTTP verbs
|
||||
- `http.put(url, body, headers)` - PUT requests
|
||||
- `http.delete(url, headers)` - DELETE requests
|
||||
- `http.delete(url, headers)` - DELETE requests
|
||||
- `http.patch(url, body, headers)` - PATCH requests
|
||||
- `http.clearCookies()` - Clear all cookies for the extension
|
||||
- **Persistent Cookie Jar**: Each extension now has its own cookie jar
|
||||
@@ -164,6 +547,7 @@
|
||||
## [3.0.0-alpha.1] - 2026-01-11
|
||||
|
||||
#### Extension System
|
||||
|
||||
- **Custom Search Providers**: Extensions can now provide custom search functionality
|
||||
- YouTube, SoundCloud, and other platforms via extensions
|
||||
- Custom search placeholder text per extension
|
||||
@@ -242,16 +626,6 @@
|
||||
- **Android Changes**:
|
||||
- `android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt`: Already had upgrade methods
|
||||
|
||||
### Documentation
|
||||
|
||||
- Updated `docs/EXTENSION_DEVELOPMENT.md`:
|
||||
- Added thumbnail ratio customization section
|
||||
- Added extension upgrade documentation
|
||||
- Added settings fields table with `secret` field
|
||||
- Added new troubleshooting entries
|
||||
- Updated table of contents
|
||||
- Updated changelog
|
||||
|
||||
---
|
||||
|
||||
## [2.2.8] - 2026-01-12
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
||||
[](https://www.virustotal.com/gui/file/e1c527eacb6f5ce527af214a75aab8da060c2afc629825fff24af858439e7e6b)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -23,59 +24,58 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||
</p>
|
||||
|
||||
## Metadata Source
|
||||
## Search Source
|
||||
|
||||
SpotiFLAC supports two metadata sources for searching tracks:
|
||||
SpotiFLAC supports two search sources:
|
||||
|
||||
| Source | Pros | Cons |
|
||||
|--------|------|------|
|
||||
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
||||
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
||||
| Source | Setup |
|
||||
|--------|-------|
|
||||
| **Deezer** (Default) | No setup required |
|
||||
| **Spotify** | Install **Spotify Web** extension from the Store, or use your own [Spotify Developer](https://developer.spotify.com) Client ID & Secret in Settings |
|
||||
|
||||
### Using Spotify
|
||||
To use Spotify as your search source without hitting rate limits:
|
||||
1. Create a Spotify Developer account at [developer.spotify.com](https://developer.spotify.com)
|
||||
2. Create an app to get your Client ID and Client Secret
|
||||
3. Go to **Settings > Options > Spotify API > Change from Deezer to Spotify > Input Custom Credentials**
|
||||
4. Enter your Client ID and Secret
|
||||
5. Change **Search Source** to Spotify
|
||||
## Extensions
|
||||
|
||||
## Extensions (Alpha)
|
||||
|
||||
> **Alpha Feature**: Extensions are now available in alpha. Some features may be unstable or change in future releases.
|
||||
|
||||
SpotiFLAC supports extensions to add custom metadata and download providers. Extensions are written in JavaScript and run in a secure sandbox.
|
||||
|
||||
### Features
|
||||
- **Metadata Providers**: Add new sources for track/album/artist search
|
||||
- **Download Providers**: Add new sources for audio downloads
|
||||
- **Custom Settings**: Extensions can have user-configurable settings
|
||||
- **Provider Priority**: Set the order in which providers are tried
|
||||
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.
|
||||
|
||||
### Installing Extensions
|
||||
1. Download a `.spotiflac-ext` file
|
||||
2. Go to **Settings > Extensions**
|
||||
3. Tap **Install Extension** and select the file
|
||||
1. Go to **Store** tab in the app
|
||||
2. Browse and install extensions with one tap
|
||||
3. Or download a `.spotiflac-ext` file and install manually via **Settings > Extensions**
|
||||
4. Configure extension settings if needed
|
||||
5. Set provider priority in **Settings > Extensions > Provider Priority**
|
||||
|
||||
### Developing Extensions
|
||||
Want to create your own extension? Check out the [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md) for complete documentation.
|
||||
|
||||
### Example Extensions
|
||||
Sample extensions are available in the [docs/extensions_example](docs/extensions_example) folder:
|
||||
Want to create your own extension? Check out the [Extension Development Guide](https://zarz.moe/docs) for complete documentation.
|
||||
|
||||
## Other project
|
||||
|
||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Why is my download failing with "Song not found"?**
|
||||
A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
|
||||
|
||||
**Q: Why are some tracks downloading in lower quality?**
|
||||
A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
|
||||
|
||||
**Q: Can I download my Spotify playlists?**
|
||||
A: Yes! Just paste the Spotify playlist URL in the search bar. The app will fetch all tracks and queue them for download.
|
||||
|
||||
**Q: Why do I need to grant storage permission?**
|
||||
A: The app needs permission to save downloaded files to your device. On Android 13+, you may need to grant "All files access" in Settings > Apps > SpotiFLAC > Permissions.
|
||||
|
||||
**Q: How do I download Daily Mix or Discover Weekly?**
|
||||
A: Install the **Spotify Web** extension from the Store. This extension can access personalized playlists that aren't available through the public API.
|
||||
|
||||
**Q: Is this app safe?**
|
||||
A: Yes, the app is open source and you can verify the code yourself. Each release is scanned with VirusTotal (see badge at top of README).
|
||||
|
||||
[](https://ko-fi.com/zarzet)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
> **iOS Support**: This app is primarily tested on Android. iOS support is experimental and may have bugs — the developer is too poor to afford an iPhone for proper testing. If you encounter issues on iOS, please report them!
|
||||
|
||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
||||
|
||||
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
|
||||
|
||||
@@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"cancelDownload" -> {
|
||||
val itemId = call.argument<String>("item_id") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
Gobackend.cancelDownload(itemId)
|
||||
}
|
||||
result.success(null)
|
||||
}
|
||||
"setDownloadDirectory" -> {
|
||||
val path = call.argument<String>("path") ?: ""
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -572,6 +579,30 @@ class MainActivity: FlutterActivity() {
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getAlbumWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val albumId = call.argument<String>("album_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getAlbumWithExtensionJSON(extensionId, albumId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getPlaylistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val playlistId = call.argument<String>("playlist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getPlaylistWithExtensionJSON(extensionId, playlistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
"getArtistWithExtension" -> {
|
||||
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||
val artistId = call.argument<String>("artist_id") ?: ""
|
||||
val response = withContext(Dispatchers.IO) {
|
||||
Gobackend.getArtistWithExtensionJSON(extensionId, artistId)
|
||||
}
|
||||
result.success(response)
|
||||
}
|
||||
// Extension Post-Processing API
|
||||
"runPostProcessing" -> {
|
||||
val filePath = call.argument<String>("file_path") ?: ""
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,3 @@
|
||||
files:
|
||||
- source: /lib/l10n/arb/app_en.arb
|
||||
translation: /lib/l10n/arb/app_%locale_with_underscore%.arb
|
||||
@@ -1,9 +1,11 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -346,13 +348,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
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)
|
||||
}
|
||||
@@ -361,6 +371,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
|
||||
resp, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -400,6 +413,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
||||
// Check for any errors
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
@@ -527,6 +543,9 @@ 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 errors.Is(err, ErrDownloadCancelled) {
|
||||
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrDownloadCancelled is returned when a download is cancelled by the user.
|
||||
var ErrDownloadCancelled = errors.New("download cancelled")
|
||||
|
||||
type cancelEntry struct {
|
||||
cancel context.CancelFunc
|
||||
canceled bool
|
||||
}
|
||||
|
||||
var (
|
||||
cancelMu sync.Mutex
|
||||
cancelMap = make(map[string]*cancelEntry)
|
||||
)
|
||||
|
||||
func initDownloadCancel(itemID string) context.Context {
|
||||
if itemID == "" {
|
||||
return context.Background()
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
defer cancelMu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancelMap[itemID] = &cancelEntry{
|
||||
cancel: cancel,
|
||||
canceled: false,
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func cancelDownload(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
if ok {
|
||||
entry.canceled = true
|
||||
if entry.cancel != nil {
|
||||
entry.cancel()
|
||||
}
|
||||
} else {
|
||||
cancelMap[itemID] = &cancelEntry{canceled: true}
|
||||
}
|
||||
cancelMu.Unlock()
|
||||
|
||||
// Hide progress for cancelled items.
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
func isDownloadCancelled(itemID string) bool {
|
||||
if itemID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
entry, ok := cancelMap[itemID]
|
||||
canceled := ok && entry.canceled
|
||||
cancelMu.Unlock()
|
||||
return canceled
|
||||
}
|
||||
|
||||
func clearDownloadCancel(itemID string) {
|
||||
if itemID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cancelMu.Lock()
|
||||
delete(cancelMap, itemID)
|
||||
cancelMu.Unlock()
|
||||
}
|
||||
@@ -9,10 +9,20 @@ import (
|
||||
|
||||
// Spotify image size codes (same as PC version)
|
||||
const (
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640
|
||||
spotifySize300 = "ab67616d00001e02" // 300x300 (small)
|
||||
spotifySize640 = "ab67616d0000b273" // 640x640 (medium)
|
||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||
)
|
||||
|
||||
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
||||
// Same logic as PC version for consistency
|
||||
func convertSmallToMedium(imageURL string) string {
|
||||
if strings.Contains(imageURL, spotifySize300) {
|
||||
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
// downloadCoverToMemory downloads cover art and returns as bytes (no file creation)
|
||||
// This avoids file permission issues on Android
|
||||
func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
@@ -20,17 +30,27 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return nil, fmt.Errorf("no cover URL provided")
|
||||
}
|
||||
|
||||
fmt.Printf("[Cover] Downloading cover from: %s\n", coverURL)
|
||||
GoLog("[Cover] Original URL: %s", coverURL)
|
||||
|
||||
// Upgrade to max quality if requested
|
||||
downloadURL := coverURL
|
||||
// First upgrade small (300) to medium (640) - always do this
|
||||
downloadURL := convertSmallToMedium(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||
}
|
||||
|
||||
// Then upgrade to max quality if requested
|
||||
if maxQuality {
|
||||
downloadURL = upgradeToMaxQuality(coverURL)
|
||||
if downloadURL != coverURL {
|
||||
fmt.Printf("[Cover] Upgraded to max quality URL: %s\n", downloadURL)
|
||||
maxURL := upgradeToMaxQuality(downloadURL)
|
||||
if maxURL != downloadURL {
|
||||
downloadURL = maxURL
|
||||
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
|
||||
} else {
|
||||
GoLog("[Cover] Max resolution not available, using 640x640")
|
||||
}
|
||||
}
|
||||
|
||||
GoLog("[Cover] Final URL: %s", downloadURL)
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
|
||||
// Create request with User-Agent (required by Spotify CDN)
|
||||
@@ -54,12 +74,25 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("[Cover] Downloaded %d bytes\n", len(data))
|
||||
// Calculate approximate resolution from file size
|
||||
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
|
||||
sizeKB := len(data) / 1024
|
||||
var resolution string
|
||||
if sizeKB > 200 {
|
||||
resolution = "~2000x2000 (hi-res)"
|
||||
} else if sizeKB > 50 {
|
||||
resolution = "~640x640"
|
||||
} else {
|
||||
resolution = "~300x300"
|
||||
}
|
||||
GoLog("[Cover] Downloaded %d KB (%s)", sizeKB, resolution)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
||||
// Uses same logic as PC version - replaces 640x640 size code with max resolution
|
||||
// Same logic as PC version - directly replaces 640x640 size code with max resolution
|
||||
// No HEAD verification needed - Spotify CDN always serves max resolution if available
|
||||
func upgradeToMaxQuality(coverURL string) string {
|
||||
// Spotify image URLs can be upgraded by changing the size parameter
|
||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
||||
@@ -67,21 +100,7 @@ func upgradeToMaxQuality(coverURL string) string {
|
||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
||||
|
||||
if strings.Contains(coverURL, spotifySize640) {
|
||||
// Try max resolution first
|
||||
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
|
||||
// Verify max resolution URL is available
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
req, err := http.NewRequest("HEAD", maxURL, nil)
|
||||
if err == nil {
|
||||
resp, err := DoRequestWithUserAgent(client, req)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return maxURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||
}
|
||||
|
||||
return coverURL
|
||||
@@ -93,9 +112,12 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Always upgrade small to medium first
|
||||
result := convertSmallToMedium(imageURL)
|
||||
|
||||
if maxQuality {
|
||||
return upgradeToMaxQuality(imageURL)
|
||||
result = upgradeToMaxQuality(result)
|
||||
}
|
||||
|
||||
return imageURL
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -89,11 +89,9 @@ type deezerAlbumSimple struct {
|
||||
CoverBig string `json:"cover_big"`
|
||||
CoverXL string `json:"cover_xl"`
|
||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||
}
|
||||
|
||||
// ... (skip other structs as they are fine/unchanged) ...
|
||||
|
||||
// ... (in convertTrack) ...
|
||||
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||
artistName := track.Artist.Name
|
||||
if len(track.Contributors) > 0 {
|
||||
|
||||
@@ -103,6 +103,18 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||
return path, exists
|
||||
}
|
||||
|
||||
// remove deletes an ISRC entry from the index (internal use)
|
||||
func (idx *ISRCIndex) remove(isrc string) {
|
||||
if isrc == "" {
|
||||
return
|
||||
}
|
||||
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
|
||||
delete(idx.index, strings.ToUpper(isrc))
|
||||
}
|
||||
|
||||
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||
// Returns filepath if found, empty string if not found
|
||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||
@@ -138,7 +150,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
||||
|
||||
// Use index for fast lookup
|
||||
idx := GetISRCIndex(outputDir)
|
||||
return idx.lookup(isrc)
|
||||
filePath, exists := idx.lookup(isrc)
|
||||
if !exists {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if !CheckFileExists(filePath) {
|
||||
// Stale index entry; remove it and return not found.
|
||||
idx.remove(isrc)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return filePath, true
|
||||
}
|
||||
|
||||
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||
|
||||
@@ -5,9 +5,12 @@ package gobackend
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// ParseSpotifyURL parses and validates a Spotify URL
|
||||
@@ -150,6 +153,10 @@ type DownloadRequest struct {
|
||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
||||
// Enriched IDs from Odesli/song.link - used to skip search and directly fetch
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
}
|
||||
|
||||
// DownloadResponse represents the result of a download
|
||||
@@ -399,7 +406,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
DiscNumber: tidalResult.DiscNumber,
|
||||
ISRC: tidalResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||
}
|
||||
err = tidalErr
|
||||
@@ -418,7 +425,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
DiscNumber: qobuzResult.DiscNumber,
|
||||
ISRC: qobuzResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||
}
|
||||
err = qobuzErr
|
||||
@@ -437,12 +444,16 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
||||
DiscNumber: amazonResult.DiscNumber,
|
||||
ISRC: amazonResult.ISRC,
|
||||
}
|
||||
} else {
|
||||
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||
}
|
||||
err = amazonErr
|
||||
}
|
||||
|
||||
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||
return errorResponse("Download cancelled")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
// Check if file already exists
|
||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||
@@ -536,6 +547,11 @@ func ClearItemProgress(itemID string) {
|
||||
RemoveItemProgress(itemID)
|
||||
}
|
||||
|
||||
// CancelDownload cancels an in-progress download for the given item.
|
||||
func CancelDownload(itemID string) {
|
||||
cancelDownload(itemID)
|
||||
}
|
||||
|
||||
// CleanupConnections closes idle HTTP connections
|
||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||
func CleanupConnections() {
|
||||
@@ -1025,6 +1041,8 @@ func errorResponse(msg string) (string, error) {
|
||||
strings.Contains(lowerMsg, "try using vpn") ||
|
||||
strings.Contains(lowerMsg, "change dns") {
|
||||
errorType = "isp_blocked"
|
||||
} else if strings.Contains(lowerMsg, "cancel") {
|
||||
errorType = "cancelled"
|
||||
} else if strings.Contains(lowerMsg, "permission") ||
|
||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||
strings.Contains(lowerMsg, "access denied") ||
|
||||
@@ -1440,6 +1458,41 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) {
|
||||
|
||||
// ==================== EXTENSION CUSTOM SEARCH ====================
|
||||
|
||||
// EnrichTrackWithExtensionJSON enriches track metadata using the source extension
|
||||
// This is called lazily before download starts, allowing extension to fetch real ISRC etc.
|
||||
func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
// Extension not found, return original track
|
||||
return trackJSON, nil
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
// Not a metadata provider, return original
|
||||
return trackJSON, nil
|
||||
}
|
||||
|
||||
var track ExtTrackMetadata
|
||||
if err := json.Unmarshal([]byte(trackJSON), &track); err != nil {
|
||||
return trackJSON, fmt.Errorf("failed to parse track: %w", err)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
enrichedTrack, err := provider.EnrichTrack(&track)
|
||||
if err != nil {
|
||||
// Error enriching, return original
|
||||
return trackJSON, nil
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(enrichedTrack)
|
||||
if err != nil {
|
||||
return trackJSON, nil
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// CustomSearchWithExtensionJSON performs custom search using an extension
|
||||
func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
@@ -1481,6 +1534,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType, // track, album, or playlist
|
||||
"album_type": track.AlbumType, // album, single, ep, compilation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1578,6 +1633,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
}
|
||||
response["tracks"] = tracks
|
||||
@@ -1592,16 +1649,69 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
||||
"cover_url": result.Album.CoverURL,
|
||||
"release_date": result.Album.ReleaseDate,
|
||||
"total_tracks": result.Album.TotalTracks,
|
||||
"album_type": result.Album.AlbumType,
|
||||
"provider_id": result.Album.ProviderID,
|
||||
}
|
||||
}
|
||||
|
||||
// Add artist info if present
|
||||
if result.Artist != nil {
|
||||
response["artist"] = map[string]interface{}{
|
||||
"id": result.Artist.ID,
|
||||
"name": result.Artist.Name,
|
||||
"image_url": result.Artist.ImageURL,
|
||||
artistResponse := map[string]interface{}{
|
||||
"id": result.Artist.ID,
|
||||
"name": result.Artist.Name,
|
||||
"image_url": result.Artist.ImageURL,
|
||||
"header_image": result.Artist.HeaderImage,
|
||||
"listeners": result.Artist.Listeners,
|
||||
"provider_id": result.Artist.ProviderID,
|
||||
}
|
||||
|
||||
// Add albums if present
|
||||
if len(result.Artist.Albums) > 0 {
|
||||
albums := make([]map[string]interface{}, len(result.Artist.Albums))
|
||||
for i, album := range result.Artist.Albums {
|
||||
albumType := album.AlbumType
|
||||
if albumType == "" {
|
||||
albumType = "album"
|
||||
}
|
||||
albums[i] = map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"images": album.CoverURL,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": albumType,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
}
|
||||
artistResponse["albums"] = albums
|
||||
}
|
||||
|
||||
// Add top tracks if present
|
||||
if len(result.Artist.TopTracks) > 0 {
|
||||
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
||||
for i, track := range result.Artist.TopTracks {
|
||||
topTracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"spotify_id": track.SpotifyID,
|
||||
}
|
||||
}
|
||||
artistResponse["top_tracks"] = topTracks
|
||||
}
|
||||
|
||||
response["artist"] = artistResponse
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
@@ -1623,6 +1733,259 @@ func FindURLHandlerJSON(url string) string {
|
||||
return handler.extension.ID
|
||||
}
|
||||
|
||||
// GetAlbumWithExtensionJSON gets album tracks using an extension
|
||||
func GetAlbumWithExtensionJSON(extensionID, albumID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
if !ext.Enabled {
|
||||
return "", fmt.Errorf("extension '%s' is disabled", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
album, err := provider.GetAlbum(albumID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if album == nil {
|
||||
return "", fmt.Errorf("album not found")
|
||||
}
|
||||
|
||||
// Convert tracks to map format
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
// Use album cover as fallback if track doesn't have its own cover
|
||||
trackCover := track.ResolvedCoverURL()
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
tracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"tracks": tracks,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetPlaylistWithExtensionJSON gets playlist tracks using an extension
|
||||
func GetPlaylistWithExtensionJSON(extensionID, playlistID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
|
||||
// Try getPlaylist first, fall back to getAlbum (some extensions use album for playlists)
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.getPlaylist === 'function') {
|
||||
return extension.getPlaylist(%q);
|
||||
}
|
||||
if (typeof extension !== 'undefined' && typeof extension.getAlbum === 'function') {
|
||||
return extension.getAlbum(%q);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, playlistID, playlistID)
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(provider.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getPlaylist failed: %w", err)
|
||||
}
|
||||
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return "", fmt.Errorf("playlist not found")
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
// Parse into album metadata (same structure)
|
||||
var album ExtAlbumMetadata
|
||||
if err := json.Unmarshal(jsonBytes, &album); err != nil {
|
||||
return "", fmt.Errorf("failed to parse playlist: %w", err)
|
||||
}
|
||||
album.ProviderID = ext.ID
|
||||
for i := range album.Tracks {
|
||||
album.Tracks[i].ProviderID = ext.ID
|
||||
}
|
||||
|
||||
// Convert tracks to map format
|
||||
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||
for i, track := range album.Tracks {
|
||||
// Use playlist cover as fallback if track doesn't have its own cover
|
||||
trackCover := track.ResolvedCoverURL()
|
||||
if trackCover == "" {
|
||||
trackCover = album.CoverURL
|
||||
}
|
||||
tracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"cover_url": trackCover,
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"item_type": track.ItemType,
|
||||
"album_type": track.AlbumType,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"owner": album.Artists,
|
||||
"cover_url": album.CoverURL,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"tracks": tracks,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
|
||||
jsonBytes, err = json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetArtistWithExtensionJSON gets artist info and albums using an extension
|
||||
func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
ext, err := manager.GetExtension(extensionID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !ext.Manifest.IsMetadataProvider() {
|
||||
return "", fmt.Errorf("extension '%s' is not a metadata provider", extensionID)
|
||||
}
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
artist, err := provider.GetArtist(artistID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if artist == nil {
|
||||
return "", fmt.Errorf("artist not found")
|
||||
}
|
||||
|
||||
// Convert albums to map format
|
||||
albums := make([]map[string]interface{}, len(artist.Albums))
|
||||
for i, album := range artist.Albums {
|
||||
albums[i] = map[string]interface{}{
|
||||
"id": album.ID,
|
||||
"name": album.Name,
|
||||
"artists": album.Artists,
|
||||
"cover_url": album.CoverURL,
|
||||
"release_date": album.ReleaseDate,
|
||||
"total_tracks": album.TotalTracks,
|
||||
"album_type": album.AlbumType,
|
||||
"provider_id": album.ProviderID,
|
||||
}
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": artist.ID,
|
||||
"name": artist.Name,
|
||||
"cover_url": artist.ImageURL,
|
||||
"albums": albums,
|
||||
"provider_id": artist.ProviderID,
|
||||
}
|
||||
|
||||
// Add header image if present
|
||||
if artist.HeaderImage != "" {
|
||||
response["header_image"] = artist.HeaderImage
|
||||
}
|
||||
|
||||
// Add listeners if present
|
||||
if artist.Listeners > 0 {
|
||||
response["listeners"] = artist.Listeners
|
||||
}
|
||||
|
||||
// Add top tracks if present
|
||||
if len(artist.TopTracks) > 0 {
|
||||
topTracks := make([]map[string]interface{}, len(artist.TopTracks))
|
||||
for i, track := range artist.TopTracks {
|
||||
topTracks[i] = map[string]interface{}{
|
||||
"id": track.ID,
|
||||
"name": track.Name,
|
||||
"artists": track.Artists,
|
||||
"album_name": track.AlbumName,
|
||||
"album_artist": track.AlbumArtist,
|
||||
"duration_ms": track.DurationMS,
|
||||
"images": track.ResolvedCoverURL(),
|
||||
"release_date": track.ReleaseDate,
|
||||
"track_number": track.TrackNumber,
|
||||
"disc_number": track.DiscNumber,
|
||||
"isrc": track.ISRC,
|
||||
"provider_id": track.ProviderID,
|
||||
"spotify_id": track.SpotifyID,
|
||||
}
|
||||
}
|
||||
response["top_tracks"] = topTracks
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// GetURLHandlersJSON returns all extensions that handle custom URLs
|
||||
func GetURLHandlersJSON() (string, error) {
|
||||
manager := GetExtensionManager()
|
||||
|
||||
@@ -456,9 +456,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
||||
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||
}
|
||||
|
||||
// Check if extension already loaded - skip if already exists (for directory loading on startup)
|
||||
if _, exists := m.extensions[manifest.Name]; exists {
|
||||
return nil, fmt.Errorf("Extension '%s' is already loaded", manifest.DisplayName)
|
||||
// Check if extension already loaded - skip silently (for directory loading on startup)
|
||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// Create data directory for extension
|
||||
|
||||
@@ -3,6 +3,7 @@ package gobackend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -29,6 +30,14 @@ type ExtTrackMetadata struct {
|
||||
DiscNumber int `json:"disc_number,omitempty"`
|
||||
ISRC string `json:"isrc,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ItemType string `json:"item_type,omitempty"` // track, album, or playlist - for extension search results
|
||||
AlbumType string `json:"album_type,omitempty"` // album, single, ep, compilation
|
||||
// Enrichment fields from Odesli/song.link
|
||||
TidalID string `json:"tidal_id,omitempty"`
|
||||
QobuzID string `json:"qobuz_id,omitempty"`
|
||||
DeezerID string `json:"deezer_id,omitempty"`
|
||||
SpotifyID string `json:"spotify_id,omitempty"`
|
||||
ExternalLinks map[string]string `json:"external_links,omitempty"` // service -> URL mapping
|
||||
}
|
||||
|
||||
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
||||
@@ -47,17 +56,21 @@ type ExtAlbumMetadata struct {
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
TotalTracks int `json:"total_tracks"`
|
||||
AlbumType string `json:"album_type,omitempty"`
|
||||
Tracks []ExtTrackMetadata `json:"tracks"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
// ExtArtistMetadata represents artist metadata from an extension
|
||||
type ExtArtistMetadata struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
|
||||
Listeners int `json:"listeners,omitempty"` // Monthly listeners
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"` // Popular tracks
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
||||
// ExtSearchResult represents search results from an extension
|
||||
@@ -161,8 +174,19 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
||||
}
|
||||
|
||||
var searchResult ExtSearchResult
|
||||
|
||||
// Try to parse as ExtSearchResult object first
|
||||
if err := json.Unmarshal(jsonBytes, &searchResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse search result: %w", err)
|
||||
// If that fails, try parsing as array of tracks directly
|
||||
var tracks []ExtTrackMetadata
|
||||
if arrErr := json.Unmarshal(jsonBytes, &tracks); arrErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse search result: %w (also tried array: %v)", err, arrErr)
|
||||
}
|
||||
// Wrap array in ExtSearchResult
|
||||
searchResult = ExtSearchResult{
|
||||
Tracks: tracks,
|
||||
Total: len(tracks),
|
||||
}
|
||||
}
|
||||
|
||||
// Set provider ID on all tracks
|
||||
@@ -314,6 +338,72 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
// EnrichTrack enriches track metadata before download (e.g., fetch real ISRC)
|
||||
// This is called lazily when download starts, not when playlist/album is loaded
|
||||
// Extension should implement enrichTrack(track) function that returns enriched track
|
||||
func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTrackMetadata, error) {
|
||||
if !p.extension.Manifest.IsMetadataProvider() {
|
||||
return track, nil // Not a metadata provider, return as-is
|
||||
}
|
||||
|
||||
if !p.extension.Enabled {
|
||||
return track, nil // Extension disabled, return as-is
|
||||
}
|
||||
|
||||
// Convert track to JSON for passing to JS
|
||||
trackJSON, err := json.Marshal(track)
|
||||
if err != nil {
|
||||
GoLog("[Extension] EnrichTrack: failed to marshal track: %v\n", err)
|
||||
return track, nil // Return original on error
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`
|
||||
(function() {
|
||||
if (typeof extension !== 'undefined' && typeof extension.enrichTrack === 'function') {
|
||||
var track = %s;
|
||||
return extension.enrichTrack(track);
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`, string(trackJSON))
|
||||
|
||||
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
|
||||
if err != nil {
|
||||
if IsTimeoutError(err) {
|
||||
GoLog("[Extension] EnrichTrack timeout for %s\n", p.extension.ID)
|
||||
} else {
|
||||
GoLog("[Extension] EnrichTrack error for %s: %v\n", p.extension.ID, err)
|
||||
}
|
||||
return track, nil // Return original on error
|
||||
}
|
||||
|
||||
// If extension doesn't implement enrichTrack or returns null, return original
|
||||
if result == nil || goja.IsUndefined(result) || goja.IsNull(result) {
|
||||
return track, nil
|
||||
}
|
||||
|
||||
exported := result.Export()
|
||||
jsonBytes, err := json.Marshal(exported)
|
||||
if err != nil {
|
||||
GoLog("[Extension] EnrichTrack: failed to marshal result: %v\n", err)
|
||||
return track, nil
|
||||
}
|
||||
|
||||
var enrichedTrack ExtTrackMetadata
|
||||
if err := json.Unmarshal(jsonBytes, &enrichedTrack); err != nil {
|
||||
GoLog("[Extension] EnrichTrack: failed to parse enriched track: %v\n", err)
|
||||
return track, nil
|
||||
}
|
||||
|
||||
// Preserve provider ID
|
||||
enrichedTrack.ProviderID = track.ProviderID
|
||||
|
||||
GoLog("[Extension] EnrichTrack: enriched track from %s (ISRC: %s -> %s)\n",
|
||||
p.extension.ID, track.ISRC, enrichedTrack.ISRC)
|
||||
|
||||
return &enrichedTrack, nil
|
||||
}
|
||||
|
||||
// ==================== Download Provider Methods ====================
|
||||
|
||||
// CheckAvailability checks if a track is available for download
|
||||
@@ -624,6 +714,58 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
var lastErr error
|
||||
var skipBuiltIn bool // If source extension has skipBuiltInFallback, don't try built-in providers
|
||||
|
||||
// LAZY ENRICHMENT: If track came from an extension, try to enrich metadata (e.g., get real ISRC)
|
||||
// This is done lazily at download time, not when playlist/album is loaded
|
||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||
ext, err := extManager.GetExtension(req.Source)
|
||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsMetadataProvider() {
|
||||
GoLog("[DownloadWithExtensionFallback] Enriching track from extension '%s'...\n", req.Source)
|
||||
|
||||
provider := NewExtensionProviderWrapper(ext)
|
||||
trackMeta := &ExtTrackMetadata{
|
||||
ID: req.SpotifyID,
|
||||
Name: req.TrackName,
|
||||
Artists: req.ArtistName,
|
||||
AlbumName: req.AlbumName,
|
||||
DurationMS: req.DurationMS,
|
||||
ISRC: req.ISRC,
|
||||
ReleaseDate: req.ReleaseDate,
|
||||
TrackNumber: req.TrackNumber,
|
||||
DiscNumber: req.DiscNumber,
|
||||
ProviderID: req.Source,
|
||||
}
|
||||
|
||||
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
||||
if err == nil && enrichedTrack != nil {
|
||||
// Update request with enriched data
|
||||
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
||||
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
||||
req.ISRC = enrichedTrack.ISRC
|
||||
}
|
||||
// Update service-specific IDs from Odesli enrichment
|
||||
if enrichedTrack.TidalID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Tidal ID from Odesli: %s\n", enrichedTrack.TidalID)
|
||||
req.TidalID = enrichedTrack.TidalID
|
||||
}
|
||||
if enrichedTrack.QobuzID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Qobuz ID from Odesli: %s\n", enrichedTrack.QobuzID)
|
||||
req.QobuzID = enrichedTrack.QobuzID
|
||||
}
|
||||
if enrichedTrack.DeezerID != "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Deezer ID from Odesli: %s\n", enrichedTrack.DeezerID)
|
||||
req.DeezerID = enrichedTrack.DeezerID
|
||||
}
|
||||
// Can also update other fields if needed
|
||||
if enrichedTrack.Name != "" {
|
||||
req.TrackName = enrichedTrack.Name
|
||||
}
|
||||
if enrichedTrack.Artists != "" {
|
||||
req.ArtistName = enrichedTrack.Artists
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If source extension is specified, try it first before the priority list
|
||||
if req.Source != "" && !isBuiltInProvider(req.Source) {
|
||||
GoLog("[DownloadWithExtensionFallback] Track source is extension '%s', trying it first\n", req.Source)
|
||||
@@ -697,6 +839,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: req.Source,
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
} else if result.ErrorMessage != "" {
|
||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
@@ -741,6 +891,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
return result, nil
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: providerID,
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
||||
}
|
||||
@@ -826,6 +984,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return &DownloadResponse{
|
||||
Success: false,
|
||||
Error: "Download cancelled",
|
||||
ErrorType: "cancelled",
|
||||
Service: providerID,
|
||||
}, nil
|
||||
}
|
||||
lastErr = err
|
||||
} else if result.ErrorMessage != "" {
|
||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||
@@ -1075,6 +1241,25 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
||||
for i := range handleResult.Tracks {
|
||||
handleResult.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
if handleResult.Album != nil {
|
||||
handleResult.Album.ProviderID = p.extension.ID
|
||||
for i := range handleResult.Album.Tracks {
|
||||
handleResult.Album.Tracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
if handleResult.Artist != nil {
|
||||
handleResult.Artist.ProviderID = p.extension.ID
|
||||
for i := range handleResult.Artist.Albums {
|
||||
handleResult.Artist.Albums[i].ProviderID = p.extension.ID
|
||||
for j := range handleResult.Artist.Albums[i].Tracks {
|
||||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
// Set provider ID on top tracks
|
||||
for i := range handleResult.Artist.TopTracks {
|
||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
|
||||
return &handleResult, nil
|
||||
}
|
||||
|
||||
@@ -498,7 +498,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
}
|
||||
|
||||
// Find udta atom inside moov, or create one
|
||||
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
||||
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(data[moovPos+3]))
|
||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||
|
||||
// Build new metadata atoms
|
||||
@@ -507,12 +507,12 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
var newData []byte
|
||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||
// udta exists, find meta inside it or replace
|
||||
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
||||
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(data[udtaPos+3]))
|
||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||
|
||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||
// Replace existing meta atom
|
||||
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
||||
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(data[metaPos+3]))
|
||||
newData = append(newData, data[:metaPos]...)
|
||||
newData = append(newData, metaAtom...)
|
||||
newData = append(newData, data[metaPos+metaSize:]...)
|
||||
@@ -570,7 +570,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
||||
// findAtom finds an atom by name starting from offset
|
||||
func findAtom(data []byte, name string, offset int) int {
|
||||
for i := offset; i < len(data)-8; {
|
||||
size := int(data[i]<<24 | data[i+1]<<16 | data[i+2]<<8 | data[i+3])
|
||||
size := int(uint32(data[i])<<24 | uint32(data[i+1])<<16 | uint32(data[i+2])<<8 | uint32(data[i+3]))
|
||||
if size < 8 {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -240,6 +240,9 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
|
||||
|
||||
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||
return 0, ErrDownloadCancelled
|
||||
}
|
||||
n, err := pw.writer.Write(p)
|
||||
if err != nil {
|
||||
return n, err
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -64,24 +66,27 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||
expectedFirst = strings.TrimSpace(expectedFirst)
|
||||
// Split expected artists by common separators (comma, feat, ft., &, and)
|
||||
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
||||
expectedArtists := qobuzSplitArtists(normExpected)
|
||||
foundArtists := qobuzSplitArtists(normFound)
|
||||
|
||||
foundFirst := strings.Split(normFound, ",")[0]
|
||||
foundFirst = strings.Split(foundFirst, " feat")[0]
|
||||
foundFirst = strings.Split(foundFirst, " ft.")[0]
|
||||
foundFirst = strings.TrimSpace(foundFirst)
|
||||
|
||||
if expectedFirst == foundFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||
return true
|
||||
// Check if ANY expected artist matches ANY found artist
|
||||
for _, exp := range expectedArtists {
|
||||
for _, fnd := range foundArtists {
|
||||
if exp == fnd {
|
||||
return true
|
||||
}
|
||||
// Also check contains for partial matches
|
||||
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||
return true
|
||||
}
|
||||
// Check same words different order
|
||||
if qobuzSameWordsUnordered(exp, fnd) {
|
||||
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
@@ -96,6 +101,67 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// qobuzSplitArtists splits artist string by common separators
|
||||
func qobuzSplitArtists(artists string) []string {
|
||||
// Replace common separators with a standard one
|
||||
normalized := artists
|
||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " ft ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " & ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " and ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, ", ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " x ", "|")
|
||||
|
||||
parts := strings.Split(normalized, "|")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// qobuzSameWordsUnordered checks if two strings have the same words regardless of order
|
||||
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
|
||||
func qobuzSameWordsUnordered(a, b string) bool {
|
||||
wordsA := strings.Fields(a)
|
||||
wordsB := strings.Fields(b)
|
||||
|
||||
// Must have same number of words
|
||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort and compare
|
||||
sortedA := make([]string, len(wordsA))
|
||||
sortedB := make([]string, len(wordsB))
|
||||
copy(sortedA, wordsA)
|
||||
copy(sortedB, wordsB)
|
||||
|
||||
// Simple bubble sort (usually just 2-3 words)
|
||||
for i := 0; i < len(sortedA)-1; i++ {
|
||||
for j := i + 1; j < len(sortedA); j++ {
|
||||
if sortedA[i] > sortedA[j] {
|
||||
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
|
||||
}
|
||||
if sortedB[i] > sortedB[j] {
|
||||
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range sortedA {
|
||||
if sortedA[i] != sortedB[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// qobuzTitlesMatch checks if track titles are similar enough
|
||||
func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
@@ -303,6 +369,35 @@ func NewQobuzDownloader() *QobuzDownloader {
|
||||
return globalQobuzDownloader
|
||||
}
|
||||
|
||||
// GetTrackByID fetches track info directly by Qobuz track ID
|
||||
func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) {
|
||||
// Qobuz API: /track/get?track_id=XXX
|
||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9nZXQ/dHJhY2tfaWQ9")
|
||||
trackURL := fmt.Sprintf("%s%d&app_id=%s", string(apiBase), trackID, q.appID)
|
||||
|
||||
req, err := http.NewRequest("GET", trackURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("get track failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var track QobuzTrack
|
||||
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &track, nil
|
||||
}
|
||||
|
||||
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||
// Uses same APIs as PC version for compatibility
|
||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||
@@ -725,12 +820,10 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
||||
|
||||
// Collect results - return first success
|
||||
var errors []string
|
||||
var firstSuccess *qobuzAPIResult
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil && firstSuccess == nil {
|
||||
firstSuccess = &result
|
||||
if result.err == nil {
|
||||
GoLog("[Qobuz] [Parallel] ✓ Got response from %s in %v\n", result.apiURL, result.duration)
|
||||
|
||||
// Drain remaining results to avoid goroutine leaks
|
||||
@@ -741,91 +834,19 @@ func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (
|
||||
}(len(apis) - i - 1)
|
||||
|
||||
GoLog("[Qobuz] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
|
||||
return firstSuccess.apiURL, firstSuccess.downloadURL, nil
|
||||
} else if result.err != nil {
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
return result.apiURL, result.downloadURL, nil
|
||||
}
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
}
|
||||
|
||||
GoLog("[Qobuz] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// getQobuzDownloadURLSequential requests download URL from APIs sequentially
|
||||
// Uses same URL format as PC version: /api/stream?trackId={id}&quality={quality}
|
||||
func getQobuzDownloadURLSequential(apis []string, trackID int64, quality string) (string, string, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", "", fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
retryConfig := DefaultRetryConfig()
|
||||
var errors []string
|
||||
|
||||
for _, apiURL := range apis {
|
||||
// All APIs now use same format: https://domain/api/stream?trackId={id}&quality={quality}
|
||||
// The apiURL already includes the path, just append trackID and quality
|
||||
reqURL := fmt.Sprintf("%s%d&quality=%s", apiURL, trackID, quality)
|
||||
|
||||
GoLog("[Qobuz] Trying: %s\n", reqURL)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
||||
if err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if response is HTML (error page)
|
||||
if len(body) > 0 && body[0] == '<' {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "received HTML instead of JSON"))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for error in JSON response
|
||||
var errorResp struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, errorResp.Error))
|
||||
continue
|
||||
}
|
||||
|
||||
var result struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "invalid JSON: "+err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
if result.URL != "" {
|
||||
GoLog("[Qobuz] Got download URL from: %s\n", apiURL)
|
||||
return apiURL, result.URL, nil
|
||||
}
|
||||
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL in response"))
|
||||
}
|
||||
|
||||
return "", "", fmt.Errorf("all %d Qobuz APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
|
||||
@@ -845,19 +866,30 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
||||
|
||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize item progress (required for all downloads)
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
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(q.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -897,6 +929,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
// Check for any errors
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
@@ -946,8 +981,23 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
var track *QobuzTrack
|
||||
var err error
|
||||
|
||||
// STRATEGY 0: Use pre-fetched Qobuz ID from Odesli enrichment (highest priority)
|
||||
if req.QobuzID != "" {
|
||||
GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID)
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(req.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
track, err = downloader.GetTrackByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Qobuz] Failed to get track by Odesli ID %d: %v\n", trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Qobuz] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Performer.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if req.ISRC != "" {
|
||||
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)
|
||||
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||
@@ -1061,6 +1111,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||
|
||||
// Download audio file with item ID for progress tracking
|
||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package gobackend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -738,13 +740,11 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
|
||||
// Collect results - return first success
|
||||
var errors []string
|
||||
var firstSuccess *tidalAPIResult
|
||||
|
||||
for i := 0; i < len(apis); i++ {
|
||||
result := <-resultChan
|
||||
if result.err == nil && firstSuccess == nil {
|
||||
if result.err == nil {
|
||||
// First success - use this one
|
||||
firstSuccess = &result
|
||||
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
||||
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
||||
|
||||
@@ -756,109 +756,19 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
||||
}(len(apis) - i - 1)
|
||||
|
||||
GoLog("[Tidal] [Parallel] Total time: %v (first success)\n", time.Since(startTime))
|
||||
return firstSuccess.apiURL, firstSuccess.info, nil
|
||||
} else if result.err != nil {
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
return result.apiURL, result.info, nil
|
||||
}
|
||||
errMsg := result.err.Error()
|
||||
if len(errMsg) > 50 {
|
||||
errMsg = errMsg[:50] + "..."
|
||||
}
|
||||
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||
}
|
||||
|
||||
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
|
||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||
if len(apis) == 0 {
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||
}
|
||||
|
||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||
retryConfig := DefaultRetryConfig()
|
||||
var errors []string
|
||||
|
||||
for _, apiURL := range apis {
|
||||
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", apiURL, trackID, quality)
|
||||
GoLog("[Tidal] Trying API: %s\n", reqURL)
|
||||
|
||||
req, err := http.NewRequest("GET", reqURL, nil)
|
||||
if err != nil {
|
||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := DoRequestWithRetry(client, req, retryConfig)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] API error: %v\n", err)
|
||||
errors = append(errors, BuildErrorMessage(apiURL, 0, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := ReadResponseBody(resp)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Read body error: %v\n", err)
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
// Log response preview
|
||||
bodyPreview := string(body)
|
||||
if len(bodyPreview) > 300 {
|
||||
bodyPreview = bodyPreview[:300] + "..."
|
||||
}
|
||||
GoLog("[Tidal] API response (HTTP %d): %s\n", resp.StatusCode, bodyPreview)
|
||||
|
||||
// Try v2 format first (object with manifest)
|
||||
var v2Response TidalAPIResponseV2
|
||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||
GoLog("[Tidal] Got v2 response from %s - Quality: %d-bit/%dHz, AssetPresentation: %s\n",
|
||||
apiURL, v2Response.Data.BitDepth, v2Response.Data.SampleRate, v2Response.Data.AssetPresentation)
|
||||
|
||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||
GoLog("[Tidal] ✗ Rejecting PREVIEW response from %s, trying next API...\n", apiURL)
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "returned PREVIEW instead of FULL"))
|
||||
continue
|
||||
}
|
||||
|
||||
GoLog("[Tidal] ✓ Got FULL track from %s\n", apiURL)
|
||||
info := TidalDownloadInfo{
|
||||
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||
BitDepth: v2Response.Data.BitDepth,
|
||||
SampleRate: v2Response.Data.SampleRate,
|
||||
}
|
||||
return apiURL, info, nil
|
||||
}
|
||||
|
||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||
var v1Responses []struct {
|
||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||
for _, item := range v1Responses {
|
||||
if item.OriginalTrackURL != "" {
|
||||
// v1 format doesn't have quality info, assume 16-bit/44.1kHz
|
||||
info := TidalDownloadInfo{
|
||||
URL: item.OriginalTrackURL,
|
||||
BitDepth: 16,
|
||||
SampleRate: 44100,
|
||||
}
|
||||
return apiURL, info, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
errors = append(errors, BuildErrorMessage(apiURL, resp.StatusCode, "no download URL or manifest in response"))
|
||||
}
|
||||
|
||||
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||
}
|
||||
|
||||
// GetDownloadURL gets download URL for a track - tries ALL APIs in parallel
|
||||
// "Siapa cepat dia dapat" - first successful response wins
|
||||
func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDownloadInfo, error) {
|
||||
@@ -978,29 +888,45 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
||||
|
||||
// DownloadFile downloads a file from URL with progress tracking
|
||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// Handle manifest-based download (DASH/BTS)
|
||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||
// Initialize progress tracking for manifest downloads
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||
}
|
||||
|
||||
// Initialize item progress for direct downloads
|
||||
if itemID != "" {
|
||||
StartItemProgress(itemID)
|
||||
defer CompleteItemProgress(itemID)
|
||||
ctx = initDownloadCancel(itemID)
|
||||
defer clearDownloadCancel(itemID)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||
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(t.client, req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -1040,6 +966,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
// Check for any errors
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if flushErr != nil {
|
||||
@@ -1060,7 +989,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error {
|
||||
func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error {
|
||||
fmt.Println("[Tidal] Parsing manifest...")
|
||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||
if err != nil {
|
||||
@@ -1079,7 +1008,11 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
||||
|
||||
req, err := http.NewRequest("GET", directURL, nil)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", directURL, nil)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
@@ -1087,6 +1020,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] BTS download failed: %v\n", err)
|
||||
return fmt.Errorf("failed to download file: %w", err)
|
||||
}
|
||||
@@ -1122,6 +1058,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
|
||||
if err != nil {
|
||||
os.Remove(outputPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
return fmt.Errorf("download interrupted: %w", err)
|
||||
}
|
||||
if closeErr != nil {
|
||||
@@ -1154,10 +1093,25 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
|
||||
// Download initialization segment
|
||||
GoLog("[Tidal] Downloading init segment...\n")
|
||||
resp, err := client.Get(initURL)
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
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)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Init segment download failed: %v\n", err)
|
||||
return fmt.Errorf("failed to download init segment: %w", err)
|
||||
}
|
||||
@@ -1173,6 +1127,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
||||
return fmt.Errorf("failed to write init segment: %w", err)
|
||||
}
|
||||
@@ -1180,6 +1137,12 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
// Download media segments with progress
|
||||
totalSegments := len(mediaURLs)
|
||||
for i, mediaURL := range mediaURLs {
|
||||
if isDownloadCancelled(itemID) {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
|
||||
if i%10 == 0 || i == totalSegments-1 {
|
||||
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||
}
|
||||
@@ -1190,10 +1153,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
SetItemProgress(itemID, progress, 0, 0)
|
||||
}
|
||||
|
||||
resp, err := client.Get(mediaURL)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
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)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||
}
|
||||
@@ -1209,6 +1182,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
||||
if err != nil {
|
||||
out.Close()
|
||||
os.Remove(m4aPath)
|
||||
if isDownloadCancelled(itemID) {
|
||||
return ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||
}
|
||||
@@ -1253,24 +1229,27 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check first artist (before comma or feat)
|
||||
spotifyFirst := strings.Split(normSpotify, ",")[0]
|
||||
spotifyFirst = strings.Split(spotifyFirst, " feat")[0]
|
||||
spotifyFirst = strings.Split(spotifyFirst, " ft.")[0]
|
||||
spotifyFirst = strings.TrimSpace(spotifyFirst)
|
||||
// Split artists by common separators (comma, feat, ft., &, and)
|
||||
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
||||
spotifyArtists := splitArtists(normSpotify)
|
||||
tidalArtists := splitArtists(normTidal)
|
||||
|
||||
tidalFirst := strings.Split(normTidal, ",")[0]
|
||||
tidalFirst = strings.Split(tidalFirst, " feat")[0]
|
||||
tidalFirst = strings.Split(tidalFirst, " ft.")[0]
|
||||
tidalFirst = strings.TrimSpace(tidalFirst)
|
||||
|
||||
if spotifyFirst == tidalFirst {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if first artist is contained in the other
|
||||
if strings.Contains(spotifyFirst, tidalFirst) || strings.Contains(tidalFirst, spotifyFirst) {
|
||||
return true
|
||||
// Check if ANY expected artist matches ANY found artist
|
||||
for _, exp := range spotifyArtists {
|
||||
for _, fnd := range tidalArtists {
|
||||
if exp == fnd {
|
||||
return true
|
||||
}
|
||||
// Also check contains for partial matches
|
||||
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||
return true
|
||||
}
|
||||
// Check same words different order
|
||||
if sameWordsUnordered(exp, fnd) {
|
||||
GoLog("[Tidal] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
||||
@@ -1286,6 +1265,67 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// splitArtists splits artist string by common separators
|
||||
func splitArtists(artists string) []string {
|
||||
// Replace common separators with a standard one
|
||||
normalized := artists
|
||||
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " feat ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " ft. ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " ft ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " & ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " and ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, ", ", "|")
|
||||
normalized = strings.ReplaceAll(normalized, " x ", "|")
|
||||
|
||||
parts := strings.Split(normalized, "|")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed != "" {
|
||||
result = append(result, trimmed)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// sameWordsUnordered checks if two strings have the same words regardless of order
|
||||
// Useful for Japanese names: "Sawano Hiroyuki" vs "Hiroyuki Sawano"
|
||||
func sameWordsUnordered(a, b string) bool {
|
||||
wordsA := strings.Fields(a)
|
||||
wordsB := strings.Fields(b)
|
||||
|
||||
// Must have same number of words
|
||||
if len(wordsA) != len(wordsB) || len(wordsA) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sort and compare
|
||||
sortedA := make([]string, len(wordsA))
|
||||
sortedB := make([]string, len(wordsB))
|
||||
copy(sortedA, wordsA)
|
||||
copy(sortedB, wordsB)
|
||||
|
||||
// Simple bubble sort (usually just 2-3 words)
|
||||
for i := 0; i < len(sortedA)-1; i++ {
|
||||
for j := i + 1; j < len(sortedA); j++ {
|
||||
if sortedA[i] > sortedA[j] {
|
||||
sortedA[i], sortedA[j] = sortedA[j], sortedA[i]
|
||||
}
|
||||
if sortedB[i] > sortedB[j] {
|
||||
sortedB[i], sortedB[j] = sortedB[j], sortedB[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range sortedA {
|
||||
if sortedA[i] != sortedB[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// titlesMatch checks if track titles are similar enough
|
||||
func titlesMatch(expectedTitle, foundTitle string) bool {
|
||||
normExpected := strings.ToLower(strings.TrimSpace(expectedTitle))
|
||||
@@ -1485,8 +1525,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
var track *TidalTrack
|
||||
var err error
|
||||
|
||||
// STRATEGY 0: Use pre-fetched Tidal ID from Odesli enrichment (highest priority)
|
||||
if req.TidalID != "" {
|
||||
GoLog("[Tidal] Using Tidal ID from Odesli enrichment: %s\n", req.TidalID)
|
||||
// Parse track ID (could be a number or extracted from URL)
|
||||
var trackID int64
|
||||
if _, parseErr := fmt.Sscanf(req.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 {
|
||||
track, err = downloader.GetTrackInfoByID(trackID)
|
||||
if err != nil {
|
||||
GoLog("[Tidal] Failed to get track by Odesli ID %d: %v\n", trackID, err)
|
||||
track = nil
|
||||
} else if track != nil {
|
||||
GoLog("[Tidal] Successfully found track via Odesli ID: '%s' by '%s'\n", track.Title, track.Artist.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OPTIMIZATION: Check cache first for track ID
|
||||
if req.ISRC != "" {
|
||||
if track == nil && req.ISRC != "" {
|
||||
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
||||
@@ -1520,7 +1576,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
|
||||
// Strategy 2: Try SongLink if we have Spotify ID
|
||||
if track == nil && req.SpotifyID != "" {
|
||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||
var tidalURL string
|
||||
@@ -1698,6 +1754,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||
}())
|
||||
|
||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
||||
if errors.Is(err, ErrDownloadCancelled) {
|
||||
return TidalDownloadResult{}, ErrDownloadCancelled
|
||||
}
|
||||
GoLog("[Tidal] Download failed with error: %v\n", err)
|
||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -120,6 +120,12 @@ import Gobackend // Import Go framework
|
||||
let itemId = args["item_id"] as! String
|
||||
GobackendClearItemProgress(itemId)
|
||||
return nil
|
||||
|
||||
case "cancelDownload":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let itemId = args["item_id"] as! String
|
||||
GobackendCancelDownload(itemId)
|
||||
return nil
|
||||
|
||||
case "setDownloadDirectory":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -503,6 +509,30 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getAlbumWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let albumId = args["album_id"] as! String
|
||||
let response = GobackendGetAlbumWithExtensionJSON(extensionId, albumId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getPlaylistWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let playlistId = args["playlist_id"] as! String
|
||||
let response = GobackendGetPlaylistWithExtensionJSON(extensionId, playlistId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getArtistWithExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let artistId = args["artist_id"] as! String
|
||||
let response = GobackendGetArtistWithExtensionJSON(extensionId, artistId, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Post-Processing API
|
||||
case "runPostProcessing":
|
||||
let args = call.arguments as! [String: Any]
|
||||
@@ -517,6 +547,47 @@ import Gobackend // Import Go framework
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
// Extension Store
|
||||
case "initExtensionStore":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let cacheDir = args["cache_dir"] as! String
|
||||
GobackendInitExtensionStoreJSON(cacheDir, &error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
case "getStoreExtensions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let forceRefresh = args["force_refresh"] as? Bool ?? false
|
||||
let response = GobackendGetStoreExtensionsJSON(forceRefresh, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "searchStoreExtensions":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let query = args["query"] as? String ?? ""
|
||||
let category = args["category"] as? String ?? ""
|
||||
let response = GobackendSearchStoreExtensionsJSON(query, category, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "getStoreCategories":
|
||||
let response = GobackendGetStoreCategoriesJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "downloadStoreExtension":
|
||||
let args = call.arguments as! [String: Any]
|
||||
let extensionId = args["extension_id"] as! String
|
||||
let destDir = args["dest_dir"] as! String
|
||||
let response = GobackendDownloadStoreExtensionJSON(extensionId, destDir, &error)
|
||||
if let error = error { throw error }
|
||||
return response
|
||||
|
||||
case "clearStoreCache":
|
||||
GobackendClearStoreCacheJSON(&error)
|
||||
if let error = error { throw error }
|
||||
return nil
|
||||
|
||||
default:
|
||||
throw NSError(
|
||||
domain: "SpotiFLAC",
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>de</string>
|
||||
<string>es</string>
|
||||
<string>fr</string>
|
||||
<string>hi</string>
|
||||
<string>id</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>nl</string>
|
||||
<string>pt</string>
|
||||
<string>ru</string>
|
||||
<string>zh</string>
|
||||
<string>zh-Hans</string>
|
||||
<string>zh-Hant</string>
|
||||
</array>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>SpotiFLAC</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
arb-dir: lib/l10n/arb
|
||||
template-arb-file: app_en.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: AppLocalizations
|
||||
output-dir: lib/l10n
|
||||
nullable-getter: false
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
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/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) {
|
||||
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
||||
@@ -31,6 +33,13 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(_routerProvider);
|
||||
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||
|
||||
// Convert locale string to Locale object
|
||||
Locale? locale;
|
||||
if (localeString != 'system') {
|
||||
locale = Locale(localeString);
|
||||
}
|
||||
|
||||
return DynamicColorWrapper(
|
||||
builder: (lightTheme, darkTheme, themeMode) {
|
||||
@@ -43,6 +52,15 @@ class SpotiFLACApp extends ConsumerWidget {
|
||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||
themeAnimationCurve: Curves.easeInOut,
|
||||
routerConfig: router,
|
||||
// Localization
|
||||
locale: locale, // null = follow system
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.0.0-beta.1';
|
||||
static const String buildNumber = '54';
|
||||
static const String version = '3.1.0';
|
||||
static const String buildNumber = '59';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
{
|
||||
"@@locale": "id",
|
||||
"@@last_modified": "2026-01-16",
|
||||
|
||||
"appName": "SpotiFLAC",
|
||||
"appDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
|
||||
|
||||
"navHome": "Beranda",
|
||||
"navHistory": "Riwayat",
|
||||
"navSettings": "Pengaturan",
|
||||
"navStore": "Toko",
|
||||
|
||||
"homeTitle": "Beranda",
|
||||
"homeSearchHint": "Tempel URL Spotify atau cari...",
|
||||
"homeSearchHintExtension": "Cari dengan {extensionName}...",
|
||||
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
|
||||
"homeSupports": "Mendukung: URL Track, Album, Playlist, Artis",
|
||||
"homeRecent": "Terbaru",
|
||||
|
||||
"historyTitle": "Riwayat",
|
||||
"historyDownloading": "Mengunduh ({count})",
|
||||
"historyDownloaded": "Terunduh",
|
||||
"historyFilterAll": "Semua",
|
||||
"historyFilterAlbums": "Album",
|
||||
"historyFilterSingles": "Single",
|
||||
"historyTracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
|
||||
"historyAlbumsCount": "{count, plural, =1{1 album} other{{count} album}}",
|
||||
"historyNoDownloads": "Tidak ada riwayat unduhan",
|
||||
"historyNoDownloadsSubtitle": "Lagu yang diunduh akan muncul di sini",
|
||||
"historyNoAlbums": "Tidak ada unduhan album",
|
||||
"historyNoAlbumsSubtitle": "Unduh beberapa lagu dari album untuk melihatnya di sini",
|
||||
"historyNoSingles": "Tidak ada unduhan single",
|
||||
"historyNoSinglesSubtitle": "Unduhan lagu satuan akan muncul di sini",
|
||||
|
||||
"settingsTitle": "Pengaturan",
|
||||
"settingsDownload": "Unduhan",
|
||||
"settingsAppearance": "Tampilan",
|
||||
"settingsOptions": "Opsi",
|
||||
"settingsExtensions": "Ekstensi",
|
||||
"settingsAbout": "Tentang",
|
||||
|
||||
"downloadTitle": "Unduhan",
|
||||
"downloadLocation": "Lokasi Unduhan",
|
||||
"downloadLocationSubtitle": "Pilih tempat menyimpan file",
|
||||
"downloadLocationDefault": "Lokasi default",
|
||||
"downloadDefaultService": "Layanan Default",
|
||||
"downloadDefaultServiceSubtitle": "Layanan yang digunakan untuk unduhan",
|
||||
"downloadDefaultQuality": "Kualitas Default",
|
||||
"downloadAskQuality": "Tanya Kualitas Sebelum Unduh",
|
||||
"downloadAskQualitySubtitle": "Tampilkan pemilih kualitas untuk setiap unduhan",
|
||||
"downloadFilenameFormat": "Format Nama File",
|
||||
"downloadFolderOrganization": "Organisasi Folder",
|
||||
"downloadSeparateSingles": "Pisahkan Single",
|
||||
"downloadSeparateSinglesSubtitle": "Letakkan lagu satuan di folder terpisah",
|
||||
|
||||
"qualityBest": "Terbaik",
|
||||
"qualityFlac": "FLAC",
|
||||
"quality320": "320 kbps",
|
||||
"quality128": "128 kbps",
|
||||
|
||||
"appearanceTitle": "Tampilan",
|
||||
"appearanceTheme": "Tema",
|
||||
"appearanceThemeSystem": "Sistem",
|
||||
"appearanceThemeLight": "Terang",
|
||||
"appearanceThemeDark": "Gelap",
|
||||
"appearanceDynamicColor": "Warna Dinamis",
|
||||
"appearanceDynamicColorSubtitle": "Gunakan warna dari wallpaper Anda",
|
||||
"appearanceAccentColor": "Warna Aksen",
|
||||
"appearanceHistoryView": "Tampilan Riwayat",
|
||||
"appearanceHistoryViewList": "Daftar",
|
||||
"appearanceHistoryViewGrid": "Grid",
|
||||
|
||||
"optionsTitle": "Opsi",
|
||||
"optionsSearchSource": "Sumber Pencarian",
|
||||
"optionsPrimaryProvider": "Provider Utama",
|
||||
"optionsPrimaryProviderSubtitle": "Layanan yang digunakan saat mencari berdasarkan nama lagu.",
|
||||
"optionsUsingExtension": "Menggunakan ekstensi: {extensionName}",
|
||||
"optionsSwitchBack": "Ketuk Deezer atau Spotify untuk beralih dari ekstensi",
|
||||
"optionsAutoFallback": "Auto Fallback",
|
||||
"optionsAutoFallbackSubtitle": "Coba layanan lain jika unduhan gagal",
|
||||
"optionsUseExtensionProviders": "Gunakan Provider Ekstensi",
|
||||
"optionsUseExtensionProvidersOn": "Ekstensi akan dicoba terlebih dahulu",
|
||||
"optionsUseExtensionProvidersOff": "Hanya menggunakan provider bawaan",
|
||||
"optionsEmbedLyrics": "Sematkan Lirik",
|
||||
"optionsEmbedLyricsSubtitle": "Sematkan lirik sinkron ke file FLAC",
|
||||
"optionsMaxQualityCover": "Cover Kualitas Maksimal",
|
||||
"optionsMaxQualityCoverSubtitle": "Unduh cover art resolusi tertinggi",
|
||||
"optionsConcurrentDownloads": "Unduhan Bersamaan",
|
||||
"optionsConcurrentSequential": "Berurutan (1 per waktu)",
|
||||
"optionsConcurrentParallel": "{count} unduhan paralel",
|
||||
"optionsConcurrentWarning": "Unduhan paralel dapat memicu pembatasan rate",
|
||||
"optionsExtensionStore": "Toko Ekstensi",
|
||||
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
|
||||
"optionsCheckUpdates": "Periksa Pembaruan",
|
||||
"optionsCheckUpdatesSubtitle": "Beritahu saat versi baru tersedia",
|
||||
"optionsUpdateChannel": "Saluran Pembaruan",
|
||||
"optionsUpdateChannelStable": "Hanya rilis stabil",
|
||||
"optionsUpdateChannelPreview": "Dapatkan rilis preview",
|
||||
"optionsUpdateChannelWarning": "Preview mungkin mengandung bug atau fitur belum lengkap",
|
||||
"optionsClearHistory": "Hapus Riwayat Unduhan",
|
||||
"optionsClearHistorySubtitle": "Hapus semua lagu dari riwayat",
|
||||
"optionsDetailedLogging": "Log Detail",
|
||||
"optionsDetailedLoggingOn": "Log detail sedang direkam",
|
||||
"optionsDetailedLoggingOff": "Aktifkan untuk laporan bug",
|
||||
"optionsSpotifyCredentials": "Kredensial Spotify",
|
||||
"optionsSpotifyCredentialsConfigured": "Client ID: {clientId}...",
|
||||
"optionsSpotifyCredentialsRequired": "Diperlukan - ketuk untuk mengatur",
|
||||
"optionsSpotifyWarning": "Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com",
|
||||
|
||||
"extensionsTitle": "Ekstensi",
|
||||
"extensionsInstalled": "Ekstensi Terpasang",
|
||||
"extensionsNone": "Tidak ada ekstensi terpasang",
|
||||
"extensionsNoneSubtitle": "Pasang ekstensi dari tab Toko",
|
||||
"extensionsEnabled": "Aktif",
|
||||
"extensionsDisabled": "Nonaktif",
|
||||
"extensionsVersion": "Versi {version}",
|
||||
"extensionsAuthor": "oleh {author}",
|
||||
"extensionsUninstall": "Copot",
|
||||
"extensionsSetAsSearch": "Jadikan Provider Pencarian",
|
||||
|
||||
"storeTitle": "Toko Ekstensi",
|
||||
"storeSearch": "Cari ekstensi...",
|
||||
"storeInstall": "Pasang",
|
||||
"storeInstalled": "Terpasang",
|
||||
"storeUpdate": "Perbarui",
|
||||
|
||||
"aboutTitle": "Tentang",
|
||||
"aboutContributors": "Kontributor",
|
||||
"aboutMobileDeveloper": "Pengembang versi mobile",
|
||||
"aboutOriginalCreator": "Pencipta SpotiFLAC asli",
|
||||
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kami yang indah!",
|
||||
"aboutSpecialThanks": "Terima Kasih Khusus",
|
||||
"aboutLinks": "Tautan",
|
||||
"aboutMobileSource": "Kode sumber mobile",
|
||||
"aboutPCSource": "Kode sumber PC",
|
||||
"aboutReportIssue": "Laporkan masalah",
|
||||
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
|
||||
"aboutFeatureRequest": "Permintaan fitur",
|
||||
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
|
||||
"aboutSupport": "Dukungan",
|
||||
"aboutBuyMeCoffee": "Traktir saya kopi",
|
||||
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
|
||||
"aboutApp": "Aplikasi",
|
||||
"aboutVersion": "Versi",
|
||||
|
||||
"albumTitle": "Album",
|
||||
"albumTracks": "{count, plural, =1{1 lagu} other{{count} lagu}}",
|
||||
"albumDownloadAll": "Unduh Semua",
|
||||
"albumDownloadRemaining": "Unduh Sisanya",
|
||||
|
||||
"playlistTitle": "Playlist",
|
||||
"artistTitle": "Artis",
|
||||
"artistAlbums": "Album",
|
||||
"artistSingles": "Single & EP",
|
||||
|
||||
"trackMetadataTitle": "Info Lagu",
|
||||
"trackMetadataArtist": "Artis",
|
||||
"trackMetadataAlbum": "Album",
|
||||
"trackMetadataDuration": "Durasi",
|
||||
"trackMetadataQuality": "Kualitas",
|
||||
"trackMetadataPath": "Lokasi File",
|
||||
"trackMetadataDownloadedAt": "Diunduh",
|
||||
"trackMetadataService": "Layanan",
|
||||
"trackMetadataPlay": "Putar",
|
||||
"trackMetadataShare": "Bagikan",
|
||||
"trackMetadataDelete": "Hapus",
|
||||
"trackMetadataRedownload": "Unduh ulang",
|
||||
"trackMetadataOpenFolder": "Buka Folder",
|
||||
|
||||
"setupTitle": "Selamat Datang di SpotiFLAC",
|
||||
"setupSubtitle": "Mari mulai pengaturan",
|
||||
"setupStoragePermission": "Izin Penyimpanan",
|
||||
"setupStoragePermissionSubtitle": "Diperlukan untuk menyimpan file unduhan",
|
||||
"setupStoragePermissionGranted": "Izin diberikan",
|
||||
"setupStoragePermissionDenied": "Izin ditolak",
|
||||
"setupGrantPermission": "Berikan Izin",
|
||||
"setupDownloadLocation": "Lokasi Unduhan",
|
||||
"setupChooseFolder": "Pilih Folder",
|
||||
"setupContinue": "Lanjutkan",
|
||||
"setupSkip": "Lewati untuk sekarang",
|
||||
|
||||
"dialogCancel": "Batal",
|
||||
"dialogOk": "OK",
|
||||
"dialogSave": "Simpan",
|
||||
"dialogDelete": "Hapus",
|
||||
"dialogRetry": "Coba Lagi",
|
||||
"dialogClose": "Tutup",
|
||||
"dialogYes": "Ya",
|
||||
"dialogNo": "Tidak",
|
||||
"dialogClear": "Hapus",
|
||||
"dialogConfirm": "Konfirmasi",
|
||||
"dialogDone": "Selesai",
|
||||
|
||||
"dialogClearHistoryTitle": "Hapus Riwayat",
|
||||
"dialogClearHistoryMessage": "Apakah Anda yakin ingin menghapus semua riwayat unduhan? Ini tidak dapat dibatalkan.",
|
||||
"dialogDeleteSelectedTitle": "Hapus yang Dipilih",
|
||||
"dialogDeleteSelectedMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari riwayat?\n\nIni juga akan menghapus file dari penyimpanan.",
|
||||
"dialogImportPlaylistTitle": "Impor Playlist",
|
||||
"dialogImportPlaylistMessage": "Ditemukan {count} lagu di CSV. Tambahkan ke antrian unduhan?",
|
||||
|
||||
"snackbarAddedToQueue": "Menambahkan \"{trackName}\" ke antrian",
|
||||
"snackbarAddedTracksToQueue": "Menambahkan {count} lagu ke antrian",
|
||||
"snackbarAlreadyDownloaded": "\"{trackName}\" sudah diunduh",
|
||||
"snackbarHistoryCleared": "Riwayat dihapus",
|
||||
"snackbarCredentialsSaved": "Kredensial disimpan",
|
||||
"snackbarCredentialsCleared": "Kredensial dihapus",
|
||||
"snackbarDeletedTracks": "Menghapus {count} {count, plural, =1{lagu} other{lagu}}",
|
||||
"snackbarCannotOpenFile": "Tidak dapat membuka file: {error}",
|
||||
"snackbarFillAllFields": "Harap isi semua field",
|
||||
"snackbarViewQueue": "Lihat Antrian",
|
||||
|
||||
"errorRateLimited": "Dibatasi",
|
||||
"errorRateLimitedMessage": "Terlalu banyak permintaan. Harap tunggu sebentar sebelum mencari lagi.",
|
||||
"errorFailedToLoad": "Gagal memuat {item}",
|
||||
"errorNoTracksFound": "Tidak ada lagu ditemukan",
|
||||
"errorMissingExtensionSource": "Tidak dapat memuat {item}: sumber ekstensi tidak ada",
|
||||
|
||||
"statusQueued": "Mengantri",
|
||||
"statusDownloading": "Mengunduh",
|
||||
"statusFinalizing": "Menyelesaikan",
|
||||
"statusCompleted": "Selesai",
|
||||
"statusFailed": "Gagal",
|
||||
"statusSkipped": "Dilewati",
|
||||
"statusPaused": "Dijeda",
|
||||
|
||||
"actionPause": "Jeda",
|
||||
"actionResume": "Lanjutkan",
|
||||
"actionCancel": "Batal",
|
||||
"actionStop": "Hentikan",
|
||||
"actionSelect": "Pilih",
|
||||
"actionSelectAll": "Pilih Semua",
|
||||
"actionDeselect": "Batal Pilih",
|
||||
"actionPaste": "Tempel",
|
||||
"actionImportCsv": "Impor CSV",
|
||||
"actionRemoveCredentials": "Hapus Kredensial",
|
||||
"actionSaveCredentials": "Simpan Kredensial",
|
||||
|
||||
"selectionSelected": "{count} dipilih",
|
||||
"selectionAllSelected": "Semua lagu dipilih",
|
||||
"selectionTapToSelect": "Ketuk lagu untuk memilih",
|
||||
"selectionDeleteTracks": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
|
||||
"selectionSelectToDelete": "Pilih lagu untuk dihapus",
|
||||
|
||||
"progressFetchingMetadata": "Mengambil metadata... {current}/{total}",
|
||||
"progressReadingCsv": "Membaca CSV...",
|
||||
|
||||
"searchSongs": "Lagu",
|
||||
"searchArtists": "Artis",
|
||||
"searchAlbums": "Album",
|
||||
"searchPlaylists": "Playlist",
|
||||
|
||||
"tooltipPlay": "Putar",
|
||||
"tooltipCancel": "Batal",
|
||||
"tooltipStop": "Hentikan",
|
||||
"tooltipRetry": "Coba Lagi",
|
||||
"tooltipRemove": "Hapus",
|
||||
"tooltipClear": "Hapus",
|
||||
"tooltipPaste": "Tempel",
|
||||
|
||||
"filenameFormat": "Format Nama File",
|
||||
"filenameFormatPreview": "Pratinjau: {preview}",
|
||||
"folderOrganization": "Organisasi Folder",
|
||||
"folderOrganizationNone": "Tanpa organisasi",
|
||||
"folderOrganizationByArtist": "Berdasarkan Artis",
|
||||
"folderOrganizationByAlbum": "Berdasarkan Album",
|
||||
"folderOrganizationByArtistAlbum": "Artis/Album",
|
||||
|
||||
"updateAvailable": "Pembaruan Tersedia",
|
||||
"updateNewVersion": "Versi {version} tersedia",
|
||||
"updateDownload": "Unduh",
|
||||
"updateLater": "Nanti",
|
||||
"updateChangelog": "Log Perubahan",
|
||||
|
||||
"providerPriority": "Prioritas Provider",
|
||||
"providerPrioritySubtitle": "Seret untuk mengatur ulang provider unduhan",
|
||||
"metadataProviderPriority": "Prioritas Provider Metadata",
|
||||
"metadataProviderPrioritySubtitle": "Urutan yang digunakan saat mengambil metadata lagu",
|
||||
|
||||
"logTitle": "Log",
|
||||
"logCopy": "Salin Log",
|
||||
"logClear": "Hapus Log",
|
||||
"logShare": "Bagikan Log",
|
||||
"logEmpty": "Belum ada log",
|
||||
"logCopied": "Log disalin ke clipboard",
|
||||
|
||||
"credentialsTitle": "Kredensial Spotify",
|
||||
"credentialsDescription": "Masukkan Client ID dan Secret Anda untuk menggunakan kuota aplikasi Spotify Anda sendiri.",
|
||||
"credentialsClientId": "Client ID",
|
||||
"credentialsClientIdHint": "Tempel Client ID",
|
||||
"credentialsClientSecret": "Client Secret",
|
||||
"credentialsClientSecretHint": "Tempel Client Secret",
|
||||
|
||||
"channelStable": "Stabil",
|
||||
"channelPreview": "Preview",
|
||||
|
||||
"sectionSearchSource": "Sumber Pencarian",
|
||||
"sectionDownload": "Unduhan",
|
||||
"sectionPerformance": "Performa",
|
||||
"sectionApp": "Aplikasi",
|
||||
"sectionData": "Data",
|
||||
"sectionDebug": "Debug",
|
||||
"sectionService": "Layanan",
|
||||
"sectionAudioQuality": "Kualitas Audio",
|
||||
"sectionFileSettings": "Pengaturan File",
|
||||
"sectionColor": "Warna",
|
||||
"sectionTheme": "Tema",
|
||||
"sectionLayout": "Tata Letak",
|
||||
"sectionLanguage": "Bahasa",
|
||||
|
||||
"appearanceLanguage": "Bahasa Aplikasi",
|
||||
"appearanceLanguageSubtitle": "Pilih bahasa yang kamu inginkan",
|
||||
"languageSystem": "Bawaan Sistem",
|
||||
"languageEnglish": "English",
|
||||
"languageIndonesian": "Bahasa Indonesia",
|
||||
|
||||
"settingsAppearanceSubtitle": "Tema, warna, tampilan",
|
||||
"settingsDownloadSubtitle": "Layanan, kualitas, format nama file",
|
||||
"settingsOptionsSubtitle": "Fallback, lirik, cover art, pembaruan",
|
||||
"settingsExtensionsSubtitle": "Kelola provider unduhan",
|
||||
"settingsLogsSubtitle": "Lihat log aplikasi untuk debugging",
|
||||
|
||||
"loadingSharedLink": "Memuat link yang dibagikan...",
|
||||
"pressBackAgainToExit": "Tekan kembali sekali lagi untuk keluar",
|
||||
|
||||
"artistReleases": "{count, plural, =1{1 rilis} other{{count} rilis}}",
|
||||
"artistCompilations": "Kompilasi",
|
||||
"artistPopular": "Populer",
|
||||
"artistMonthlyListeners": "{count} pendengar bulanan",
|
||||
|
||||
"tracksHeader": "Lagu",
|
||||
"downloadAllCount": "Unduh Semua ({count})",
|
||||
"tracksCount": "{count, plural, =1{1 lagu} other{{count} lagu}}",
|
||||
|
||||
"setupStorageAccessRequired": "Akses Penyimpanan Diperlukan",
|
||||
"setupStorageAccessMessage": "SpotiFLAC membutuhkan izin \"Akses semua file\" untuk menyimpan file musik ke folder pilihan Anda.",
|
||||
"setupStorageAccessMessageAndroid11": "Android 11+ memerlukan izin \"Akses semua file\" untuk menyimpan file ke folder unduhan pilihan Anda.",
|
||||
"setupOpenSettings": "Buka Pengaturan",
|
||||
"setupPermissionDeniedMessage": "Izin ditolak. Harap berikan semua izin untuk melanjutkan.",
|
||||
"setupPermissionRequired": "Izin {permissionType} Diperlukan",
|
||||
"setupPermissionRequiredMessage": "Izin {permissionType} diperlukan untuk pengalaman terbaik. Anda dapat mengubahnya nanti di Pengaturan.",
|
||||
"setupSelectDownloadFolder": "Pilih Folder Unduhan",
|
||||
"setupUseDefaultFolder": "Gunakan Folder Default?",
|
||||
"setupNoFolderSelected": "Tidak ada folder dipilih. Apakah Anda ingin menggunakan folder Musik default?",
|
||||
"setupUseDefault": "Gunakan Default",
|
||||
"setupDownloadLocationTitle": "Lokasi Unduhan",
|
||||
"setupDownloadLocationIosMessage": "Di iOS, unduhan disimpan ke folder Documents aplikasi. Anda dapat mengaksesnya melalui aplikasi Files.",
|
||||
"setupAppDocumentsFolder": "Folder Documents Aplikasi",
|
||||
"setupAppDocumentsFolderSubtitle": "Direkomendasikan - dapat diakses via aplikasi Files",
|
||||
"setupChooseFromFiles": "Pilih dari Files",
|
||||
"setupChooseFromFilesSubtitle": "Pilih lokasi iCloud atau lainnya",
|
||||
"setupIosEmptyFolderWarning": "Batasan iOS: Folder kosong tidak dapat dipilih. Pilih folder dengan minimal satu file.",
|
||||
"setupDownloadInFlac": "Unduh lagu Spotify dalam format FLAC",
|
||||
"setupStepStorage": "Penyimpanan",
|
||||
"setupStepNotification": "Notifikasi",
|
||||
"setupStepFolder": "Folder",
|
||||
"setupStepSpotify": "Spotify",
|
||||
"setupStepPermission": "Izin",
|
||||
"setupStorageGranted": "Izin Penyimpanan Diberikan!",
|
||||
"setupStorageRequired": "Izin Penyimpanan Diperlukan",
|
||||
"setupStorageDescription": "SpotiFLAC membutuhkan izin penyimpanan untuk menyimpan file musik yang diunduh.",
|
||||
"setupNotificationGranted": "Izin Notifikasi Diberikan!",
|
||||
"setupNotificationEnable": "Aktifkan Notifikasi",
|
||||
"setupNotificationDescription": "Dapatkan pemberitahuan saat unduhan selesai atau membutuhkan perhatian.",
|
||||
"setupFolderSelected": "Folder Unduhan Dipilih!",
|
||||
"setupFolderChoose": "Pilih Folder Unduhan",
|
||||
"setupFolderDescription": "Pilih folder tempat musik yang diunduh akan disimpan.",
|
||||
"setupChangeFolder": "Ubah Folder",
|
||||
"setupSelectFolder": "Pilih Folder",
|
||||
"setupSpotifyApiOptional": "Spotify API (Opsional)",
|
||||
"setupSpotifyApiDescription": "Tambahkan kredensial Spotify API untuk hasil pencarian lebih baik dan akses ke konten eksklusif Spotify.",
|
||||
"setupUseSpotifyApi": "Gunakan Spotify API",
|
||||
"setupEnterCredentialsBelow": "Masukkan kredensial Anda di bawah",
|
||||
"setupUsingDeezer": "Menggunakan Deezer (tidak perlu akun)",
|
||||
"setupEnterClientId": "Masukkan Spotify Client ID",
|
||||
"setupEnterClientSecret": "Masukkan Spotify Client Secret",
|
||||
"setupGetFreeCredentials": "Dapatkan kredensial API gratis dari Spotify Developer Dashboard.",
|
||||
"setupEnableNotifications": "Aktifkan Notifikasi",
|
||||
|
||||
"dialogImport": "Impor",
|
||||
"dialogDiscard": "Buang",
|
||||
"dialogRemove": "Hapus",
|
||||
"dialogUninstall": "Copot",
|
||||
"dialogDiscardChanges": "Buang Perubahan?",
|
||||
"dialogUnsavedChanges": "Anda memiliki perubahan yang belum disimpan. Apakah Anda ingin membuangnya?",
|
||||
"dialogDownloadFailed": "Unduhan Gagal",
|
||||
"dialogTrackLabel": "Lagu:",
|
||||
"dialogArtistLabel": "Artis:",
|
||||
"dialogErrorLabel": "Error:",
|
||||
"dialogClearAll": "Hapus Semua",
|
||||
"dialogClearAllDownloads": "Apakah Anda yakin ingin menghapus semua unduhan?",
|
||||
"dialogRemoveFromDevice": "Hapus dari perangkat?",
|
||||
"dialogRemoveExtension": "Hapus Ekstensi",
|
||||
"dialogRemoveExtensionMessage": "Apakah Anda yakin ingin menghapus ekstensi ini? Tindakan ini tidak dapat dibatalkan.",
|
||||
"dialogUninstallExtension": "Copot Ekstensi?",
|
||||
"dialogUninstallExtensionMessage": "Apakah Anda yakin ingin menghapus {extensionName}?",
|
||||
|
||||
"snackbarFailedToLoad": "Gagal memuat: {error}",
|
||||
"snackbarUrlCopied": "URL {platform} disalin ke clipboard",
|
||||
"snackbarFileNotFound": "File tidak ditemukan",
|
||||
"snackbarSelectExtFile": "Harap pilih file .spotiflac-ext",
|
||||
"snackbarProviderPrioritySaved": "Prioritas provider disimpan",
|
||||
"snackbarMetadataProviderSaved": "Prioritas provider metadata disimpan",
|
||||
"snackbarExtensionInstalled": "{extensionName} terpasang.",
|
||||
"snackbarExtensionUpdated": "{extensionName} diperbarui.",
|
||||
"snackbarFailedToInstall": "Gagal memasang ekstensi",
|
||||
"snackbarFailedToUpdate": "Gagal memperbarui ekstensi",
|
||||
|
||||
"storeFilterAll": "Semua",
|
||||
"storeFilterMetadata": "Metadata",
|
||||
"storeFilterDownload": "Unduhan",
|
||||
"storeFilterUtility": "Utilitas",
|
||||
"storeFilterLyrics": "Lirik",
|
||||
"storeFilterIntegration": "Integrasi",
|
||||
"storeClearFilters": "Hapus filter",
|
||||
"storeNoResults": "Tidak ada ekstensi ditemukan",
|
||||
|
||||
"extensionProviderPriority": "Prioritas Provider",
|
||||
"extensionInstallButton": "Pasang Ekstensi",
|
||||
"extensionDefaultProvider": "Default (Deezer/Spotify)",
|
||||
"extensionDefaultProviderSubtitle": "Gunakan pencarian bawaan",
|
||||
"extensionAuthor": "Pembuat",
|
||||
"extensionId": "ID",
|
||||
"extensionError": "Error",
|
||||
"extensionCapabilities": "Kemampuan",
|
||||
"extensionMetadataProvider": "Provider Metadata",
|
||||
"extensionDownloadProvider": "Provider Unduhan",
|
||||
"extensionLyricsProvider": "Provider Lirik",
|
||||
"extensionUrlHandler": "Penanganan URL",
|
||||
"extensionQualityOptions": "Opsi Kualitas",
|
||||
"extensionPostProcessingHooks": "Hook Pasca-Pemrosesan",
|
||||
"extensionPermissions": "Izin",
|
||||
"extensionSettings": "Pengaturan",
|
||||
"extensionRemoveButton": "Hapus Ekstensi",
|
||||
"extensionUpdated": "Diperbarui",
|
||||
"extensionMinAppVersion": "Versi App Minimum",
|
||||
|
||||
"qualityFlacLossless": "FLAC Lossless",
|
||||
"qualityFlacLosslessSubtitle": "16-bit / 44.1kHz",
|
||||
"qualityHiResFlac": "Hi-Res FLAC",
|
||||
"qualityHiResFlacSubtitle": "24-bit / hingga 96kHz",
|
||||
"qualityHiResFlacMax": "Hi-Res FLAC Max",
|
||||
"qualityHiResFlacMaxSubtitle": "24-bit / hingga 192kHz",
|
||||
"qualityNote": "Kualitas sebenarnya tergantung ketersediaan lagu dari layanan",
|
||||
|
||||
"downloadAskBeforeDownload": "Tanya Sebelum Unduh",
|
||||
"downloadDirectory": "Direktori Unduhan",
|
||||
"downloadSeparateSinglesFolder": "Folder Singles Terpisah",
|
||||
"downloadAlbumFolderStructure": "Struktur Folder Album",
|
||||
"downloadSaveFormat": "Simpan Format",
|
||||
"downloadSelectService": "Pilih Layanan",
|
||||
"downloadSelectQuality": "Pilih Kualitas",
|
||||
"downloadFrom": "Unduh Dari",
|
||||
"downloadDefaultQualityLabel": "Kualitas Default",
|
||||
"downloadBestAvailable": "Terbaik tersedia",
|
||||
|
||||
"folderNone": "Tidak ada",
|
||||
"folderNoneSubtitle": "Simpan semua file langsung ke folder unduhan",
|
||||
"folderArtist": "Artis",
|
||||
"folderArtistSubtitle": "Nama Artis/namafile",
|
||||
"folderAlbum": "Album",
|
||||
"folderAlbumSubtitle": "Nama Album/namafile",
|
||||
"folderArtistAlbum": "Artis/Album",
|
||||
"folderArtistAlbumSubtitle": "Nama Artis/Nama Album/namafile",
|
||||
|
||||
"serviceTidal": "Tidal",
|
||||
"serviceQobuz": "Qobuz",
|
||||
"serviceAmazon": "Amazon",
|
||||
"serviceDeezer": "Deezer",
|
||||
"serviceSpotify": "Spotify",
|
||||
|
||||
"logSearchHint": "Cari log...",
|
||||
"logFilterLevel": "Level",
|
||||
"logFilterSection": "Filter",
|
||||
"logShareLogs": "Bagikan log",
|
||||
"logClearLogs": "Hapus log",
|
||||
"logClearLogsTitle": "Hapus Log",
|
||||
"logClearLogsMessage": "Apakah Anda yakin ingin menghapus semua log?",
|
||||
"logIspBlocking": "PEMBLOKIRAN ISP TERDETEKSI",
|
||||
"logRateLimited": "DIBATASI",
|
||||
"logNetworkError": "ERROR JARINGAN",
|
||||
"logTrackNotFound": "LAGU TIDAK DITEMUKAN",
|
||||
|
||||
"appearanceAmoledDark": "AMOLED Gelap",
|
||||
"appearanceAmoledDarkSubtitle": "Latar belakang hitam murni",
|
||||
"appearanceChooseAccentColor": "Pilih Warna Aksen",
|
||||
"appearanceChooseTheme": "Mode Tema",
|
||||
|
||||
"updateStartingDownload": "Memulai unduhan...",
|
||||
"updateDownloadFailed": "Unduhan gagal",
|
||||
"updateFailedMessage": "Gagal mengunduh pembaruan",
|
||||
"updateNewVersionReady": "Versi baru sudah siap",
|
||||
"updateCurrent": "Saat ini",
|
||||
"updateNew": "Baru",
|
||||
"updateDownloading": "Mengunduh...",
|
||||
"updateWhatsNew": "Yang Baru",
|
||||
"updateDownloadInstall": "Unduh & Pasang",
|
||||
"updateDontRemind": "Jangan ingatkan",
|
||||
|
||||
"trackCopyFilePath": "Salin lokasi file",
|
||||
"trackRemoveFromDevice": "Hapus dari perangkat",
|
||||
"trackLoadLyrics": "Muat Lirik",
|
||||
|
||||
"dateToday": "Hari ini",
|
||||
"dateYesterday": "Kemarin",
|
||||
"dateDaysAgo": "{count} hari lalu",
|
||||
"dateWeeksAgo": "{count} minggu lalu",
|
||||
"dateMonthsAgo": "{count} bulan lalu",
|
||||
|
||||
"concurrentSequential": "Berurutan",
|
||||
"concurrentParallel2": "2 Paralel",
|
||||
"concurrentParallel3": "3 Paralel",
|
||||
|
||||
"filenameAvailablePlaceholders": "Placeholder yang tersedia:",
|
||||
"filenameHint": "{artist} - {title}",
|
||||
|
||||
"tapToSeeError": "Ketuk untuk melihat detail error",
|
||||
|
||||
"setupProceedToNextStep": "Anda dapat melanjutkan ke langkah berikutnya.",
|
||||
"setupNotificationProgressDescription": "Anda akan menerima notifikasi progres unduhan.",
|
||||
"setupNotificationBackgroundDescription": "Dapatkan notifikasi tentang progres dan penyelesaian unduhan. Ini membantu Anda melacak unduhan saat aplikasi di latar belakang.",
|
||||
"setupSkipForNow": "Lewati untuk sekarang",
|
||||
"setupBack": "Kembali",
|
||||
"setupNext": "Lanjut",
|
||||
"setupGetStarted": "Mulai",
|
||||
"setupSkipAndStart": "Lewati & Mulai",
|
||||
"setupAllowAccessToManageFiles": "Harap aktifkan \"Izinkan akses untuk mengelola semua file\" di layar berikutnya.",
|
||||
"setupGetCredentialsFromSpotify": "Dapatkan kredensial dari developer.spotify.com",
|
||||
|
||||
"trackMetadata": "Metadata",
|
||||
"trackFileInfo": "Info File",
|
||||
"trackLyrics": "Lirik",
|
||||
"trackFileNotFound": "File tidak ditemukan",
|
||||
"trackOpenInDeezer": "Buka di Deezer",
|
||||
"trackOpenInSpotify": "Buka di Spotify",
|
||||
"trackTrackName": "Nama lagu",
|
||||
"trackArtist": "Artis",
|
||||
"trackAlbumArtist": "Artis album",
|
||||
"trackAlbum": "Album",
|
||||
"trackTrackNumber": "Nomor lagu",
|
||||
"trackDiscNumber": "Nomor disc",
|
||||
"trackDuration": "Durasi",
|
||||
"trackAudioQuality": "Kualitas audio",
|
||||
"trackReleaseDate": "Tanggal rilis",
|
||||
"trackDownloaded": "Diunduh",
|
||||
"trackCopyLyrics": "Salin lirik",
|
||||
"trackLyricsNotAvailable": "Lirik tidak tersedia untuk lagu ini",
|
||||
"trackLyricsTimeout": "Permintaan timeout. Coba lagi nanti.",
|
||||
"trackLyricsLoadFailed": "Gagal memuat lirik",
|
||||
"trackCopiedToClipboard": "Disalin ke clipboard",
|
||||
"trackDeleteConfirmTitle": "Hapus dari perangkat?",
|
||||
"trackDeleteConfirmMessage": "Ini akan menghapus file unduhan secara permanen dan menghapusnya dari riwayat Anda.",
|
||||
"trackCannotOpen": "Tidak dapat membuka: {message}",
|
||||
|
||||
"logFilterBySeverity": "Filter log berdasarkan tingkat keparahan",
|
||||
"logNoLogsYet": "Belum ada log",
|
||||
"logNoLogsYetSubtitle": "Log akan muncul di sini saat Anda menggunakan aplikasi",
|
||||
"logIssueSummary": "Ringkasan Masalah",
|
||||
"logIspBlockingDescription": "ISP Anda mungkin memblokir akses ke layanan unduhan",
|
||||
"logIspBlockingSuggestion": "Coba gunakan VPN atau ubah DNS ke 1.1.1.1 atau 8.8.8.8",
|
||||
"logRateLimitedDescription": "Terlalu banyak permintaan ke layanan",
|
||||
"logRateLimitedSuggestion": "Tunggu beberapa menit sebelum mencoba lagi",
|
||||
"logNetworkErrorDescription": "Masalah koneksi terdeteksi",
|
||||
"logNetworkErrorSuggestion": "Periksa koneksi internet Anda",
|
||||
"logTrackNotFoundDescription": "Beberapa lagu tidak dapat ditemukan di layanan unduhan",
|
||||
"logTrackNotFoundSuggestion": "Lagu mungkin tidak tersedia dalam kualitas lossless",
|
||||
"logTotalErrors": "Total error: {count}",
|
||||
"logAffected": "Terpengaruh: {domains}",
|
||||
"logEntriesFiltered": "Entri ({count} difilter)",
|
||||
"logEntries": "Entri ({count})",
|
||||
|
||||
"extensionsProviderPrioritySection": "Prioritas Provider",
|
||||
"extensionsInstalledSection": "Ekstensi Terpasang",
|
||||
"extensionsNoExtensions": "Tidak ada ekstensi terpasang",
|
||||
"extensionsNoExtensionsSubtitle": "Pasang file .spotiflac-ext untuk menambahkan provider baru",
|
||||
"extensionsInstallButton": "Pasang Ekstensi",
|
||||
"extensionsInfoTip": "Ekstensi dapat menambahkan provider metadata dan unduhan baru. Hanya pasang ekstensi dari sumber terpercaya.",
|
||||
"extensionsInstalledSuccess": "Ekstensi berhasil dipasang",
|
||||
"extensionsDownloadPriority": "Prioritas Unduhan",
|
||||
"extensionsDownloadPrioritySubtitle": "Atur urutan layanan unduhan",
|
||||
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
|
||||
"extensionsMetadataPriority": "Prioritas Metadata",
|
||||
"extensionsMetadataPrioritySubtitle": "Atur urutan sumber pencarian & metadata",
|
||||
"extensionsNoMetadataProvider": "Tidak ada ekstensi dengan provider metadata",
|
||||
"extensionsSearchProvider": "Provider Pencarian",
|
||||
"extensionsNoCustomSearch": "Tidak ada ekstensi dengan pencarian kustom",
|
||||
"extensionsSearchProviderDescription": "Pilih layanan yang digunakan untuk mencari lagu",
|
||||
"extensionsCustomSearch": "Pencarian kustom",
|
||||
"extensionsErrorLoading": "Error memuat ekstensi",
|
||||
|
||||
"extensionCustomTrackMatching": "Pencocokan Lagu Kustom",
|
||||
"extensionPostProcessing": "Pasca-Pemrosesan",
|
||||
"extensionHooksAvailable": "{count} hook tersedia",
|
||||
"extensionPatternsCount": "{count} pola",
|
||||
"extensionStrategy": "Strategi: {strategy}",
|
||||
|
||||
"aboutDoubleDouble": "DoubleDouble",
|
||||
"aboutDoubleDoubleDesc": "API luar biasa untuk unduhan Amazon Music. Terima kasih sudah membuatnya gratis!",
|
||||
"aboutDabMusic": "DAB Music",
|
||||
"aboutDabMusicDesc": "API streaming Qobuz terbaik. Unduhan Hi-Res tidak akan mungkin tanpa ini!",
|
||||
|
||||
"queueTitle": "Antrian Unduhan",
|
||||
"queueClearAll": "Hapus Semua",
|
||||
"queueClearAllMessage": "Apakah Anda yakin ingin menghapus semua unduhan?",
|
||||
|
||||
"albumFolderArtistAlbum": "Artis / Album",
|
||||
"albumFolderArtistAlbumSubtitle": "Albums/Nama Artis/Nama Album/",
|
||||
"albumFolderArtistYearAlbum": "Artis / [Tahun] Album",
|
||||
"albumFolderArtistYearAlbumSubtitle": "Albums/Nama Artis/[2005] Nama Album/",
|
||||
"albumFolderAlbumOnly": "Album Saja",
|
||||
"albumFolderAlbumOnlySubtitle": "Albums/Nama Album/",
|
||||
"albumFolderYearAlbum": "[Tahun] Album",
|
||||
"albumFolderYearAlbumSubtitle": "Albums/[2005] Nama Album/",
|
||||
|
||||
"downloadedAlbumDeleteSelected": "Hapus yang Dipilih",
|
||||
"downloadedAlbumDeleteMessage": "Hapus {count} {count, plural, =1{lagu} other{lagu}} dari album ini?\n\nIni juga akan menghapus file dari penyimpanan.",
|
||||
|
||||
"utilityFunctions": "Fungsi Utilitas",
|
||||
|
||||
"aboutMobileDeveloper": "Pengembang versi mobile",
|
||||
"aboutOriginalCreator": "Pembuat SpotiFLAC asli",
|
||||
"aboutLogoArtist": "Seniman berbakat yang membuat logo aplikasi kita yang indah!",
|
||||
"aboutBinimumDesc": "Pembuat QQDL & HiFi API. Tanpa API ini, unduhan Tidal tidak akan ada!",
|
||||
"aboutSachinsenalDesc": "Pembuat proyek HiFi asli. Fondasi dari integrasi Tidal!",
|
||||
"aboutMobileSource": "Kode sumber mobile",
|
||||
"aboutPCSource": "Kode sumber PC",
|
||||
"aboutReportIssue": "Laporkan masalah",
|
||||
"aboutReportIssueSubtitle": "Laporkan masalah yang Anda temui",
|
||||
"aboutFeatureRequest": "Permintaan fitur",
|
||||
"aboutFeatureRequestSubtitle": "Sarankan fitur baru untuk aplikasi",
|
||||
"aboutBuyMeCoffee": "Belikan saya kopi",
|
||||
"aboutBuyMeCoffeeSubtitle": "Dukung pengembangan di Ko-fi",
|
||||
"aboutVersion": "Versi",
|
||||
"aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.",
|
||||
|
||||
"providerPriorityTitle": "Prioritas Provider",
|
||||
"providerPriorityDescription": "Seret untuk mengatur ulang urutan provider unduhan. Aplikasi akan mencoba provider dari atas ke bawah saat mengunduh lagu.",
|
||||
"providerPriorityInfo": "Jika lagu tidak tersedia di provider pertama, aplikasi akan otomatis mencoba yang berikutnya.",
|
||||
"providerBuiltIn": "Bawaan",
|
||||
"providerExtension": "Ekstensi",
|
||||
|
||||
"metadataProviderPriorityTitle": "Prioritas Metadata",
|
||||
"metadataProviderPriorityDescription": "Seret untuk mengatur ulang urutan provider metadata. Aplikasi akan mencoba provider dari atas ke bawah saat mencari lagu dan mengambil metadata.",
|
||||
"metadataProviderPriorityInfo": "Deezer tidak memiliki batas rate dan direkomendasikan sebagai utama. Spotify mungkin membatasi rate setelah banyak permintaan.",
|
||||
"metadataNoRateLimits": "Tidak ada batas rate",
|
||||
"metadataMayRateLimit": "Mungkin dibatasi rate",
|
||||
|
||||
"queueEmpty": "Tidak ada unduhan dalam antrian",
|
||||
"queueEmptySubtitle": "Tambahkan lagu dari layar beranda",
|
||||
"queueClearCompleted": "Hapus yang selesai",
|
||||
"queueDownloadFailed": "Unduhan Gagal",
|
||||
"queueTrackLabel": "Lagu:",
|
||||
"queueArtistLabel": "Artis:",
|
||||
"queueErrorLabel": "Error:",
|
||||
"queueUnknownError": "Error tidak diketahui",
|
||||
|
||||
"downloadedAlbumTracksHeader": "Lagu",
|
||||
"downloadedAlbumDownloadedCount": "{count} diunduh",
|
||||
"downloadedAlbumSelectedCount": "{count} dipilih",
|
||||
"downloadedAlbumAllSelected": "Semua lagu dipilih",
|
||||
"downloadedAlbumTapToSelect": "Ketuk lagu untuk memilih",
|
||||
"downloadedAlbumDeleteCount": "Hapus {count} {count, plural, =1{lagu} other{lagu}}",
|
||||
"downloadedAlbumSelectToDelete": "Pilih lagu untuk dihapus",
|
||||
|
||||
"folderOrganizationDescription": "Atur file yang diunduh ke dalam folder",
|
||||
"folderOrganizationNone": "Tidak ada",
|
||||
"folderOrganizationNoneSubtitle": "Semua file di folder unduhan",
|
||||
"folderOrganizationByArtist": "Berdasarkan Artis",
|
||||
"folderOrganizationByArtistSubtitle": "Folder terpisah untuk setiap artis",
|
||||
"folderOrganizationByAlbum": "Berdasarkan Album",
|
||||
"folderOrganizationByAlbumSubtitle": "Folder terpisah untuk setiap album",
|
||||
"folderOrganizationByArtistAlbum": "Berdasarkan Artis & Album",
|
||||
"folderOrganizationByArtistAlbumSubtitle": "Folder bersarang untuk artis dan album",
|
||||
|
||||
"recentTypeArtist": "Artis",
|
||||
"recentTypeAlbum": "Album",
|
||||
"recentTypeSong": "Lagu",
|
||||
"recentTypePlaylist": "Playlist",
|
||||
|
||||
"recentPlaylistInfo": "Playlist: {name}",
|
||||
"errorGeneric": "Error: {message}"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
|
||||
export 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
|
||||
/// Extension to easily access AppLocalizations from BuildContext
|
||||
extension AppLocalizationsX on BuildContext {
|
||||
/// Get the AppLocalizations instance
|
||||
/// Usage: context.l10n.navHome
|
||||
AppLocalizations get l10n => AppLocalizations.of(this);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// GENERATED FILE - DO NOT EDIT
|
||||
// Generated by: dart run tool/check_translations.dart 70
|
||||
// Only languages with >= 70% translation completion are included.
|
||||
// Translation is measured by comparing VALUES (not just key existence).
|
||||
//
|
||||
// To regenerate, run: dart run tool/check_translations.dart 70
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Minimum translation completion threshold used to filter languages.
|
||||
const int translationThreshold = 70;
|
||||
|
||||
/// List of locales that meet the translation threshold.
|
||||
/// Only these languages will be available in the app.
|
||||
const List<Locale> filteredSupportedLocales = <Locale>[
|
||||
Locale('en'),
|
||||
Locale('id'),
|
||||
];
|
||||
|
||||
/// Set of locale codes for quick lookup.
|
||||
const Set<String> filteredLocaleCodes = <String>{
|
||||
'en',
|
||||
'id',
|
||||
};
|
||||
@@ -28,7 +28,9 @@ class AppSettings {
|
||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
|
||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||
final String locale; // App language: 'system', 'en', 'id', etc.
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -55,7 +57,9 @@ class AppSettings {
|
||||
this.useExtensionProviders = true, // Default: use extensions when available
|
||||
this.searchProvider, // Default: null (use Deezer/Spotify)
|
||||
this.separateSingles = false, // Default: disabled
|
||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||
this.showExtensionStore = true, // Default: show store
|
||||
this.locale = 'system', // Default: follow system language
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -82,8 +86,11 @@ class AppSettings {
|
||||
bool? enableLogging,
|
||||
bool? useExtensionProviders,
|
||||
String? searchProvider,
|
||||
bool clearSearchProvider = false, // Set to true to clear searchProvider to null
|
||||
bool? separateSingles,
|
||||
String? albumFolderStructure,
|
||||
bool? showExtensionStore,
|
||||
String? locale,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -108,9 +115,11 @@ class AppSettings {
|
||||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
useExtensionProviders: useExtensionProviders ?? this.useExtensionProviders,
|
||||
searchProvider: searchProvider ?? this.searchProvider,
|
||||
searchProvider: clearSearchProvider ? null : (searchProvider ?? this.searchProvider),
|
||||
separateSingles: separateSingles ?? this.separateSingles,
|
||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||
locale: locale ?? this.locale,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||
searchProvider: json['searchProvider'] as String?,
|
||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||
albumFolderStructure:
|
||||
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||
locale: json['locale'] as String? ?? 'system',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -61,5 +64,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'useExtensionProviders': instance.useExtensionProviders,
|
||||
'searchProvider': instance.searchProvider,
|
||||
'separateSingles': instance.separateSingles,
|
||||
'albumFolderStructure': instance.albumFolderStructure,
|
||||
'showExtensionStore': instance.showExtensionStore,
|
||||
'locale': instance.locale,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ class Track {
|
||||
final ServiceAvailability? availability;
|
||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
||||
final String? albumType; // album, single, ep, compilation (from metadata API)
|
||||
final String? itemType; // track, album, playlist - for extension search results
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
@@ -37,10 +38,23 @@ class Track {
|
||||
this.availability,
|
||||
this.source,
|
||||
this.albumType,
|
||||
this.itemType,
|
||||
});
|
||||
|
||||
/// Check if this track is a single (based on album_type metadata)
|
||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
||||
|
||||
/// Check if this is an album item (not a track)
|
||||
bool get isAlbumItem => itemType == 'album';
|
||||
|
||||
/// Check if this is a playlist item (not a track)
|
||||
bool get isPlaylistItem => itemType == 'playlist';
|
||||
|
||||
/// Check if this is an artist item (not a track)
|
||||
bool get isArtistItem => itemType == 'artist';
|
||||
|
||||
/// Check if this is a collection (album, playlist, or artist)
|
||||
bool get isCollection => isAlbumItem || isPlaylistItem || isArtistItem;
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||
|
||||
@@ -26,6 +26,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
||||
),
|
||||
source: json['source'] as String?,
|
||||
albumType: json['albumType'] as String?,
|
||||
itemType: json['itemType'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
@@ -44,6 +45,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||
'availability': instance.availability,
|
||||
'source': instance.source,
|
||||
'albumType': instance.albumType,
|
||||
'itemType': instance.itemType,
|
||||
};
|
||||
|
||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -18,6 +18,14 @@ import 'package:spotiflac_android/utils/logger.dart';
|
||||
final _log = AppLogger('DownloadQueue');
|
||||
final _historyLog = AppLogger('DownloadHistory');
|
||||
|
||||
String? _normalizeOptionalString(String? value) {
|
||||
if (value == null) return null;
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
if (trimmed.toLowerCase() == 'null') return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Download History Item model
|
||||
class DownloadHistoryItem {
|
||||
final String id;
|
||||
@@ -89,7 +97,7 @@ class DownloadHistoryItem {
|
||||
trackName: json['trackName'] as String,
|
||||
artistName: json['artistName'] as String,
|
||||
albumName: json['albumName'] as String,
|
||||
albumArtist: json['albumArtist'] as String?,
|
||||
albumArtist: _normalizeOptionalString(json['albumArtist'] as String?),
|
||||
coverUrl: json['coverUrl'] as String?,
|
||||
filePath: json['filePath'] as String,
|
||||
service: json['service'] as String,
|
||||
@@ -492,6 +500,20 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
for (final entry in items.entries) {
|
||||
final itemId = entry.key;
|
||||
final localItem = state.items
|
||||
.where((i) => i.id == itemId)
|
||||
.firstOrNull;
|
||||
if (localItem == null) {
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.skipped) {
|
||||
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
|
||||
continue;
|
||||
}
|
||||
if (localItem.status == DownloadStatus.completed ||
|
||||
localItem.status == DownloadStatus.failed) {
|
||||
continue;
|
||||
}
|
||||
final itemProgress = entry.value as Map<String, dynamic>;
|
||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||
@@ -669,8 +691,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
/// Build output directory based on folder organization setting and separateSingles
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false}) async {
|
||||
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
|
||||
String baseDir = state.outputDir;
|
||||
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
|
||||
// If separateSingles is enabled, use Albums/Singles structure
|
||||
if (separateSingles) {
|
||||
@@ -686,10 +709,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
return singlesPath;
|
||||
} else {
|
||||
// Albums go to Albums/Artist/Album structure
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
// Albums folder structure based on setting
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
final albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final year = _extractYear(track.releaseDate);
|
||||
String albumPath;
|
||||
|
||||
switch (albumFolderStructure) {
|
||||
case 'album_only':
|
||||
// Albums/Album structure (no artist folder)
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
case 'artist_year_album':
|
||||
// Albums/Artist/[Year] Album structure
|
||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
|
||||
break;
|
||||
case 'year_album':
|
||||
// Albums/[Year] Album structure (no artist folder)
|
||||
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
|
||||
break;
|
||||
default:
|
||||
// Albums/Artist/Album structure (default: artist_album)
|
||||
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
|
||||
}
|
||||
|
||||
final dir = Directory(albumPath);
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
@@ -707,7 +752,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
String subPath = '';
|
||||
switch (folderOrganization) {
|
||||
case 'artist':
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
subPath = artistName;
|
||||
break;
|
||||
case 'album':
|
||||
@@ -715,7 +760,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
subPath = albumName;
|
||||
break;
|
||||
case 'artist_album':
|
||||
final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName);
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
@@ -742,6 +787,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/// Extract year from release date (format: "2005-06-13" or "2005")
|
||||
String? _extractYear(String? releaseDate) {
|
||||
if (releaseDate == null || releaseDate.isEmpty) return null;
|
||||
// Handle both "2005-06-13" and "2005" formats
|
||||
final match = RegExp(r'^(\d{4})').firstMatch(releaseDate);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
void updateSettings(AppSettings settings) {
|
||||
state = state.copyWith(
|
||||
outputDir: settings.downloadDirectory.isNotEmpty
|
||||
@@ -844,6 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
void updateProgress(String id, double progress, {double? speedMBps}) {
|
||||
final item = state.items.where((i) => i.id == id).firstOrNull;
|
||||
if (item == null ||
|
||||
item.status == DownloadStatus.skipped ||
|
||||
item.status == DownloadStatus.completed ||
|
||||
item.status == DownloadStatus.failed) {
|
||||
return;
|
||||
}
|
||||
updateItemStatus(
|
||||
id,
|
||||
DownloadStatus.downloading,
|
||||
@@ -854,6 +914,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
void cancelItem(String id) {
|
||||
updateItemStatus(id, DownloadStatus.skipped);
|
||||
PlatformBridge.cancelDownload(id).catchError((_) {});
|
||||
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
||||
}
|
||||
|
||||
void clearCompleted() {
|
||||
@@ -972,7 +1034,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'title': track.name,
|
||||
'artist': track.artistName,
|
||||
'album': track.albumName,
|
||||
'album_artist': track.albumArtist ?? track.artistName,
|
||||
'album_artist': _normalizeOptionalString(track.albumArtist) ?? track.artistName,
|
||||
'track_number': track.trackNumber ?? 1,
|
||||
'disc_number': track.discNumber ?? 1,
|
||||
'isrc': track.isrc ?? '',
|
||||
@@ -1001,13 +1063,42 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade Spotify cover URL to max quality (~2000x2000)
|
||||
/// Same logic as Go backend cover.go
|
||||
String _upgradeToMaxQualityCover(String coverUrl) {
|
||||
const spotifySize300 = 'ab67616d00001e02'; // 300x300 (small)
|
||||
const spotifySize640 = 'ab67616d0000b273'; // 640x640 (medium)
|
||||
const spotifySizeMax = 'ab67616d000082c1'; // Max resolution (~2000x2000)
|
||||
|
||||
// First upgrade small (300) to medium (640)
|
||||
var result = coverUrl;
|
||||
if (result.contains(spotifySize300)) {
|
||||
result = result.replaceFirst(spotifySize300, spotifySize640);
|
||||
}
|
||||
|
||||
// Then upgrade medium (640) to max
|
||||
if (result.contains(spotifySize640)) {
|
||||
result = result.replaceFirst(spotifySize640, spotifySizeMax);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Embed metadata and cover to a FLAC file after M4A conversion
|
||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
|
||||
// Download cover first
|
||||
String? coverPath;
|
||||
final coverUrl = track.coverUrl;
|
||||
var coverUrl = track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
try {
|
||||
// Upgrade cover URL to max quality if setting is enabled
|
||||
if (settings.maxQualityCover) {
|
||||
coverUrl = _upgradeToMaxQualityCover(coverUrl);
|
||||
_log.d('Cover URL upgraded to max quality: $coverUrl');
|
||||
}
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId =
|
||||
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||
@@ -1046,9 +1137,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
'ALBUM': track.albumName,
|
||||
};
|
||||
|
||||
if (track.albumArtist != null) {
|
||||
metadata['ALBUMARTIST'] = track.albumArtist!;
|
||||
}
|
||||
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
|
||||
track.artistName;
|
||||
metadata['ALBUMARTIST'] = albumArtist;
|
||||
|
||||
if (track.trackNumber != null) {
|
||||
metadata['TRACKNUMBER'] = track.trackNumber.toString();
|
||||
@@ -1356,6 +1447,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||
|
||||
final currentItem = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (currentItem.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled before start, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Set currentDownload for UI reference
|
||||
state = state.copyWith(currentDownload: item);
|
||||
|
||||
@@ -1402,6 +1502,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final data = trackData;
|
||||
_log.d('Track data keys: ${data.keys.toList()}');
|
||||
_log.d('ISRC from API: ${data['isrc']}');
|
||||
_log.d('album_type from API: ${data['album_type']}');
|
||||
trackToDownload = Track(
|
||||
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
||||
name: (data['name'] as String?) ?? trackToDownload.name,
|
||||
@@ -1423,9 +1524,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
releaseDate: data['release_date'] as String?,
|
||||
deezerId: rawId,
|
||||
availability: trackToDownload.availability,
|
||||
// Preserve albumType from API response or original track
|
||||
albumType: (data['album_type'] as String?) ?? trackToDownload.albumType,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
_log.d(
|
||||
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}',
|
||||
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}, AlbumType ${trackToDownload.albumType}',
|
||||
);
|
||||
} else {
|
||||
_log.w('Unexpected track data type: ${trackData.runtimeType}');
|
||||
@@ -1442,10 +1546,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Log cover URL for debugging CSV import issues
|
||||
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||
|
||||
final normalizedAlbumArtist =
|
||||
_normalizeOptionalString(trackToDownload.albumArtist);
|
||||
|
||||
final outputDir = await _buildOutputDir(
|
||||
trackToDownload,
|
||||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
);
|
||||
|
||||
// Use quality override if set, otherwise use default from settings
|
||||
@@ -1471,7 +1579,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
@@ -1495,7 +1603,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
@@ -1516,7 +1624,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
trackName: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
outputDir: outputDir,
|
||||
filenameFormat: state.filenameFormat,
|
||||
@@ -1557,6 +1665,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
if (result['success'] == true) {
|
||||
var filePath = result['file_path'] as String?;
|
||||
|
||||
// Strip EXISTS: prefix from duplicate detection
|
||||
if (filePath != null && filePath.startsWith('EXISTS:')) {
|
||||
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
|
||||
}
|
||||
|
||||
_log.i('Download success, file: $filePath');
|
||||
|
||||
// Get actual quality from response (if available)
|
||||
@@ -1575,7 +1689,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.i('Actual quality: $actualQuality');
|
||||
}
|
||||
|
||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||
_log.d(
|
||||
@@ -1645,7 +1758,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
name: trackToDownload.name,
|
||||
artistName: trackToDownload.artistName,
|
||||
albumName: backendAlbum ?? trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: normalizedAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
duration: trackToDownload.duration,
|
||||
isrc: trackToDownload.isrc,
|
||||
@@ -1654,6 +1767,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
||||
deezerId: trackToDownload.deezerId,
|
||||
availability: trackToDownload.availability,
|
||||
albumType: trackToDownload.albumType,
|
||||
source: trackToDownload.source,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1734,6 +1849,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
// Log cover URL for debugging
|
||||
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||
|
||||
final historyAlbumArtist =
|
||||
(normalizedAlbumArtist != null &&
|
||||
normalizedAlbumArtist != trackToDownload.artistName)
|
||||
? normalizedAlbumArtist
|
||||
: null;
|
||||
|
||||
ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.addToHistory(
|
||||
@@ -1748,7 +1869,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
|
||||
? backendAlbum
|
||||
: trackToDownload.albumName,
|
||||
albumArtist: trackToDownload.albumArtist,
|
||||
albumArtist: historyAlbumArtist,
|
||||
coverUrl: trackToDownload.coverUrl,
|
||||
filePath: filePath,
|
||||
service: result['service'] as String? ?? item.service,
|
||||
@@ -1777,8 +1898,22 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
removeItem(item.id);
|
||||
}
|
||||
} else {
|
||||
final itemAfterFailure = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (itemAfterFailure.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled, skipping error handling');
|
||||
return;
|
||||
}
|
||||
|
||||
final errorMsg = result['error'] as String? ?? 'Download failed';
|
||||
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
||||
if (errorTypeStr == 'cancelled') {
|
||||
_log.i('Download was cancelled by backend, skipping error handling');
|
||||
updateItemStatus(item.id, DownloadStatus.skipped);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert error type string to enum
|
||||
DownloadErrorType errorType;
|
||||
@@ -1822,6 +1957,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
final itemAfterError = state.items.firstWhere(
|
||||
(i) => i.id == item.id,
|
||||
orElse: () => item,
|
||||
);
|
||||
if (itemAfterError.status == DownloadStatus.skipped) {
|
||||
_log.i('Download was cancelled, skipping error handling');
|
||||
return;
|
||||
}
|
||||
|
||||
_log.e('Exception: $e', e, stackTrace);
|
||||
|
||||
String errorMsg = e.toString();
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
const _recentAccessKey = 'recent_access_history';
|
||||
const _maxRecentItems = 20;
|
||||
|
||||
/// Types of items that can be accessed
|
||||
enum RecentAccessType {
|
||||
artist,
|
||||
album,
|
||||
track,
|
||||
playlist,
|
||||
}
|
||||
|
||||
/// Represents a recently accessed item
|
||||
class RecentAccessItem {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? subtitle; // Artist name for tracks/albums, null for artists
|
||||
final String? imageUrl;
|
||||
final RecentAccessType type;
|
||||
final DateTime accessedAt;
|
||||
final String? providerId; // Extension ID or 'deezer' for built-in
|
||||
|
||||
const RecentAccessItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.subtitle,
|
||||
this.imageUrl,
|
||||
required this.type,
|
||||
required this.accessedAt,
|
||||
this.providerId,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'subtitle': subtitle,
|
||||
'imageUrl': imageUrl,
|
||||
'type': type.name,
|
||||
'accessedAt': accessedAt.toIso8601String(),
|
||||
'providerId': providerId,
|
||||
};
|
||||
|
||||
factory RecentAccessItem.fromJson(Map<String, dynamic> json) {
|
||||
return RecentAccessItem(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
subtitle: json['subtitle'] as String?,
|
||||
imageUrl: json['imageUrl'] as String?,
|
||||
type: RecentAccessType.values.firstWhere(
|
||||
(e) => e.name == json['type'],
|
||||
orElse: () => RecentAccessType.track,
|
||||
),
|
||||
accessedAt: DateTime.parse(json['accessedAt'] as String),
|
||||
providerId: json['providerId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a unique key for deduplication
|
||||
String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is RecentAccessItem &&
|
||||
runtimeType == other.runtimeType &&
|
||||
uniqueKey == other.uniqueKey;
|
||||
|
||||
@override
|
||||
int get hashCode => uniqueKey.hashCode;
|
||||
}
|
||||
|
||||
/// State for recent access history
|
||||
class RecentAccessState {
|
||||
final List<RecentAccessItem> items;
|
||||
final bool isLoaded;
|
||||
|
||||
const RecentAccessState({
|
||||
this.items = const [],
|
||||
this.isLoaded = false,
|
||||
});
|
||||
|
||||
RecentAccessState copyWith({
|
||||
List<RecentAccessItem>? items,
|
||||
bool? isLoaded,
|
||||
}) {
|
||||
return RecentAccessState(
|
||||
items: items ?? this.items,
|
||||
isLoaded: isLoaded ?? this.isLoaded,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing recent access history
|
||||
class RecentAccessNotifier extends Notifier<RecentAccessState> {
|
||||
@override
|
||||
RecentAccessState build() {
|
||||
_loadHistory();
|
||||
return const RecentAccessState();
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = prefs.getString(_recentAccessKey);
|
||||
if (json != null) {
|
||||
try {
|
||||
final List<dynamic> decoded = jsonDecode(json);
|
||||
final items = decoded
|
||||
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
state = state.copyWith(items: items, isLoaded: true);
|
||||
} catch (e) {
|
||||
// Invalid JSON, start fresh
|
||||
state = state.copyWith(isLoaded: true);
|
||||
}
|
||||
} else {
|
||||
state = state.copyWith(isLoaded: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveHistory() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final json = jsonEncode(state.items.map((e) => e.toJson()).toList());
|
||||
await prefs.setString(_recentAccessKey, json);
|
||||
}
|
||||
|
||||
/// Record an access to an artist
|
||||
void recordArtistAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
String? imageUrl,
|
||||
String? providerId,
|
||||
}) {
|
||||
_recordAccess(RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.artist,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
));
|
||||
}
|
||||
|
||||
/// Record an access to an album
|
||||
void recordAlbumAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
String? artistName,
|
||||
String? imageUrl,
|
||||
String? providerId,
|
||||
}) {
|
||||
_recordAccess(RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: artistName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.album,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
));
|
||||
}
|
||||
|
||||
/// Record an access to a track
|
||||
void recordTrackAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
String? artistName,
|
||||
String? imageUrl,
|
||||
String? providerId,
|
||||
}) {
|
||||
_recordAccess(RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: artistName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.track,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
));
|
||||
}
|
||||
|
||||
/// Record an access to a playlist
|
||||
void recordPlaylistAccess({
|
||||
required String id,
|
||||
required String name,
|
||||
String? ownerName,
|
||||
String? imageUrl,
|
||||
String? providerId,
|
||||
}) {
|
||||
_recordAccess(RecentAccessItem(
|
||||
id: id,
|
||||
name: name,
|
||||
subtitle: ownerName,
|
||||
imageUrl: imageUrl,
|
||||
type: RecentAccessType.playlist,
|
||||
accessedAt: DateTime.now(),
|
||||
providerId: providerId,
|
||||
));
|
||||
}
|
||||
|
||||
void _recordAccess(RecentAccessItem item) {
|
||||
// Debug log
|
||||
// ignore: avoid_print
|
||||
print('[RecentAccess] Recording: ${item.type.name} - ${item.name} (${item.id})');
|
||||
|
||||
// Remove any existing entry with same unique key
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
.toList();
|
||||
|
||||
// Add new item at the beginning
|
||||
updatedItems.insert(0, item);
|
||||
|
||||
// Limit to max items
|
||||
if (updatedItems.length > _maxRecentItems) {
|
||||
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
|
||||
}
|
||||
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_saveHistory();
|
||||
|
||||
// Debug log
|
||||
// ignore: avoid_print
|
||||
print('[RecentAccess] Total items now: ${updatedItems.length}');
|
||||
}
|
||||
|
||||
/// Remove a specific item from history
|
||||
void removeItem(RecentAccessItem item) {
|
||||
final updatedItems = state.items
|
||||
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||
.toList();
|
||||
state = state.copyWith(items: updatedItems);
|
||||
_saveHistory();
|
||||
}
|
||||
|
||||
/// Clear all history
|
||||
void clearHistory() {
|
||||
state = state.copyWith(items: []);
|
||||
_saveHistory();
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider instance
|
||||
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
|
||||
RecentAccessNotifier.new,
|
||||
);
|
||||
@@ -196,7 +196,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
}
|
||||
|
||||
void setSearchProvider(String? provider) {
|
||||
state = state.copyWith(searchProvider: provider);
|
||||
if (provider == null || provider.isEmpty) {
|
||||
state = state.copyWith(clearSearchProvider: true);
|
||||
} else {
|
||||
state = state.copyWith(searchProvider: provider);
|
||||
}
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
@@ -217,10 +221,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAlbumFolderStructure(String structure) {
|
||||
state = state.copyWith(albumFolderStructure: structure);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setShowExtensionStore(bool enabled) {
|
||||
state = state.copyWith(showExtensionStore: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setLocale(String locale) {
|
||||
state = state.copyWith(locale: locale);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
final _log = AppLogger('StoreProvider');
|
||||
|
||||
/// Compare two semantic version strings
|
||||
/// Returns: -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
||||
int compareVersions(String v1, String v2) {
|
||||
final parts1 = v1.replaceAll(RegExp(r'^v'), '').split('.');
|
||||
final parts2 = v2.replaceAll(RegExp(r'^v'), '').split('.');
|
||||
|
||||
final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length;
|
||||
|
||||
for (var i = 0; i < maxLen; i++) {
|
||||
final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0;
|
||||
final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0;
|
||||
|
||||
if (n1 < n2) return -1;
|
||||
if (n1 > n2) return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Extension categories
|
||||
class StoreCategory {
|
||||
static const String metadata = 'metadata';
|
||||
@@ -91,6 +110,12 @@ class StoreExtension {
|
||||
hasUpdate: json['has_update'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if this extension requires a higher app version than current
|
||||
bool get requiresNewerApp {
|
||||
if (minAppVersion == null || minAppVersion!.isEmpty) return false;
|
||||
return compareVersions(minAppVersion!, AppInfo.version) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// State for extension store
|
||||
@@ -161,6 +186,11 @@ class StoreState {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Count of extensions with updates available
|
||||
int get updatesAvailableCount {
|
||||
return extensions.where((e) => e.hasUpdate).length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for managing extension store
|
||||
|
||||
@@ -17,9 +17,13 @@ class TrackState {
|
||||
final String? artistId;
|
||||
final String? artistName;
|
||||
final String? coverUrl;
|
||||
final String? headerImageUrl; // Artist header image for background
|
||||
final int? monthlyListeners; // Artist monthly listeners
|
||||
final List<ArtistAlbum>? artistAlbums; // For artist page
|
||||
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||
final List<SearchArtist>? searchArtists; // For search results
|
||||
final bool hasSearchText; // For back button handling
|
||||
final bool isShowingRecentAccess; // For recent access mode
|
||||
final String? searchExtensionId; // Extension ID used for current search results
|
||||
|
||||
const TrackState({
|
||||
@@ -32,9 +36,13 @@ class TrackState {
|
||||
this.artistId,
|
||||
this.artistName,
|
||||
this.coverUrl,
|
||||
this.headerImageUrl,
|
||||
this.monthlyListeners,
|
||||
this.artistAlbums,
|
||||
this.artistTopTracks,
|
||||
this.searchArtists,
|
||||
this.hasSearchText = false,
|
||||
this.isShowingRecentAccess = false,
|
||||
this.searchExtensionId,
|
||||
});
|
||||
|
||||
@@ -50,9 +58,13 @@ class TrackState {
|
||||
String? artistId,
|
||||
String? artistName,
|
||||
String? coverUrl,
|
||||
String? headerImageUrl,
|
||||
int? monthlyListeners,
|
||||
List<ArtistAlbum>? artistAlbums,
|
||||
List<Track>? artistTopTracks,
|
||||
List<SearchArtist>? searchArtists,
|
||||
bool? hasSearchText,
|
||||
bool? isShowingRecentAccess,
|
||||
String? searchExtensionId,
|
||||
}) {
|
||||
return TrackState(
|
||||
@@ -65,9 +77,13 @@ class TrackState {
|
||||
artistId: artistId ?? this.artistId,
|
||||
artistName: artistName ?? this.artistName,
|
||||
coverUrl: coverUrl ?? this.coverUrl,
|
||||
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
|
||||
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
|
||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
||||
searchArtists: searchArtists ?? this.searchArtists,
|
||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||
searchExtensionId: searchExtensionId,
|
||||
);
|
||||
}
|
||||
@@ -82,6 +98,7 @@ class ArtistAlbum {
|
||||
final String? coverUrl;
|
||||
final String albumType; // album, single, compilation
|
||||
final String artists;
|
||||
final String? providerId; // Extension ID if from extension
|
||||
|
||||
const ArtistAlbum({
|
||||
required this.id,
|
||||
@@ -91,6 +108,7 @@ class ArtistAlbum {
|
||||
this.coverUrl,
|
||||
required this.albumType,
|
||||
required this.artists,
|
||||
this.providerId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,13 +187,21 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
|
||||
// Parse top tracks if available
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||
|
||||
state = TrackState(
|
||||
tracks: [],
|
||||
isLoading: false,
|
||||
artistId: artistData['id'] as String?,
|
||||
artistName: artistData['name'] as String?,
|
||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
||||
headerImageUrl: artistData['header_image'] as String?,
|
||||
monthlyListeners: artistData['listeners'] as int?,
|
||||
artistAlbums: albums,
|
||||
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||
searchExtensionId: extensionId,
|
||||
);
|
||||
return;
|
||||
@@ -275,12 +301,19 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||
(e) => e.enabled && e.hasMetadataProvider,
|
||||
);
|
||||
final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions;
|
||||
final searchProvider = settings.searchProvider;
|
||||
final useExtensions =
|
||||
settings.useExtensionProviders &&
|
||||
hasActiveMetadataExtensions &&
|
||||
searchProvider != null &&
|
||||
searchProvider.isNotEmpty;
|
||||
|
||||
// Use Deezer or Spotify based on settings
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
_log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions');
|
||||
_log.i(
|
||||
'Search started: source=$source, query="$query", useExtensions=$useExtensions',
|
||||
);
|
||||
|
||||
Map<String, dynamic> results;
|
||||
List<Track> extensionTracks = [];
|
||||
@@ -453,6 +486,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
trackNumber: track.trackNumber,
|
||||
discNumber: track.discNumber,
|
||||
releaseDate: track.releaseDate,
|
||||
albumType: track.albumType,
|
||||
source: track.source,
|
||||
availability: ServiceAvailability(
|
||||
tidal: availability['tidal'] as bool? ?? false,
|
||||
qobuz: availability['qobuz'] as bool? ?? false,
|
||||
@@ -479,6 +514,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
void setSearchText(bool hasText) {
|
||||
state = state.copyWith(hasSearchText: hasText);
|
||||
}
|
||||
|
||||
/// Set recent access mode state
|
||||
void setShowingRecentAccess(bool showing) {
|
||||
state = state.copyWith(isShowingRecentAccess: showing);
|
||||
}
|
||||
|
||||
/// Set tracks from a collection (album/playlist) opened from search results
|
||||
void setTracksFromCollection({
|
||||
required List<Track> tracks,
|
||||
String? albumName,
|
||||
String? playlistName,
|
||||
String? coverUrl,
|
||||
}) {
|
||||
state = TrackState(
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
albumName: albumName,
|
||||
playlistName: playlistName,
|
||||
coverUrl: coverUrl,
|
||||
hasSearchText: state.hasSearchText,
|
||||
);
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
return Track(
|
||||
@@ -506,13 +563,16 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
// Get item_type - can be 'track', 'album', or 'playlist'
|
||||
final itemType = data['item_type']?.toString();
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
coverUrl: data['images']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
@@ -520,6 +580,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||
albumType: data['album_type']?.toString(),
|
||||
itemType: itemType,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -529,9 +590,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
name: data['name'] as String? ?? '',
|
||||
releaseDate: data['release_date'] as String? ?? '',
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: data['images'] as String?,
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
providerId: data['provider_id']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@ import 'dart:io';
|
||||
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/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/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||
|
||||
@@ -62,6 +64,19 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Record access for recent history
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId = 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,
|
||||
);
|
||||
});
|
||||
|
||||
// Priority: widget.tracks > cache > fetch
|
||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||
if (_tracks == null) {
|
||||
@@ -260,7 +275,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} tracks', 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -269,7 +284,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${tracks.length})'),
|
||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
),
|
||||
],
|
||||
@@ -289,7 +304,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Tracks', 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -324,12 +339,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
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('Added "${track.name}" to queue')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,12 +359,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
artistName: widget.albumName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
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('Added ${tracks.length} tracks to queue')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +390,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Rate Limited',
|
||||
context.l10n.errorRateLimited,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -383,7 +398,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Too many requests. Please wait a moment and try again.',
|
||||
context.l10n.errorRateLimitedMessage,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
@@ -476,7 +491,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
|
||||
@@ -1,50 +1,87 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:intl/intl.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/track_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen;
|
||||
|
||||
/// Simple in-memory cache for artist discography
|
||||
/// Simple in-memory cache for artist data
|
||||
class _ArtistCache {
|
||||
static final Map<String, _CacheEntry> _cache = {};
|
||||
static const Duration _ttl = Duration(minutes: 10);
|
||||
|
||||
static List<ArtistAlbum>? get(String artistId) {
|
||||
static _CacheEntry? get(String artistId) {
|
||||
final entry = _cache[artistId];
|
||||
if (entry == null) return null;
|
||||
if (DateTime.now().isAfter(entry.expiresAt)) {
|
||||
_cache.remove(artistId);
|
||||
return null;
|
||||
}
|
||||
return entry.albums;
|
||||
return entry;
|
||||
}
|
||||
|
||||
static void set(String artistId, List<ArtistAlbum> albums) {
|
||||
_cache[artistId] = _CacheEntry(albums, DateTime.now().add(_ttl));
|
||||
static void set(String artistId, {
|
||||
required List<ArtistAlbum> albums,
|
||||
List<Track>? topTracks,
|
||||
String? headerImageUrl,
|
||||
int? monthlyListeners,
|
||||
}) {
|
||||
_cache[artistId] = _CacheEntry(
|
||||
albums: albums,
|
||||
topTracks: topTracks,
|
||||
headerImageUrl: headerImageUrl,
|
||||
monthlyListeners: monthlyListeners,
|
||||
expiresAt: DateTime.now().add(_ttl),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheEntry {
|
||||
final List<ArtistAlbum> albums;
|
||||
final List<Track>? topTracks;
|
||||
final String? headerImageUrl;
|
||||
final int? monthlyListeners;
|
||||
final DateTime expiresAt;
|
||||
_CacheEntry(this.albums, this.expiresAt);
|
||||
|
||||
_CacheEntry({
|
||||
required this.albums,
|
||||
this.topTracks,
|
||||
this.headerImageUrl,
|
||||
this.monthlyListeners,
|
||||
required this.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
/// Artist screen with Material Expressive 3 design - shows discography
|
||||
/// Artist screen with Spotify-like design
|
||||
class ArtistScreen extends ConsumerStatefulWidget {
|
||||
final String artistId;
|
||||
final String artistName;
|
||||
final String? coverUrl;
|
||||
final List<ArtistAlbum>? albums; // Optional - will fetch if null
|
||||
final String? headerImageUrl;
|
||||
final int? monthlyListeners;
|
||||
final List<ArtistAlbum>? albums;
|
||||
final List<Track>? topTracks;
|
||||
final String? extensionId; // If set, skip fetching from Spotify/Deezer
|
||||
|
||||
const ArtistScreen({
|
||||
super.key,
|
||||
required this.artistId,
|
||||
required this.artistName,
|
||||
this.coverUrl,
|
||||
this.headerImageUrl,
|
||||
this.monthlyListeners,
|
||||
this.albums,
|
||||
this.topTracks,
|
||||
this.extensionId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -54,14 +91,62 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
||||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
bool _isLoadingDiscography = false;
|
||||
List<ArtistAlbum>? _albums;
|
||||
List<Track>? _topTracks;
|
||||
String? _headerImageUrl;
|
||||
int? _monthlyListeners;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Priority: widget.albums > cache > fetch
|
||||
_albums = widget.albums ?? _ArtistCache.get(widget.artistId);
|
||||
if (_albums == null) {
|
||||
|
||||
// Record access for recent history
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final providerId = widget.extensionId ??
|
||||
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
|
||||
ref.read(recentAccessProvider.notifier).recordArtistAccess(
|
||||
id: widget.artistId,
|
||||
name: widget.artistName,
|
||||
imageUrl: widget.coverUrl,
|
||||
providerId: providerId,
|
||||
);
|
||||
});
|
||||
|
||||
// If this is an extension artist, use provided data only - don't fetch from Spotify/Deezer
|
||||
if (widget.extensionId != null) {
|
||||
_albums = widget.albums;
|
||||
_topTracks = widget.topTracks;
|
||||
_headerImageUrl = widget.headerImageUrl;
|
||||
_monthlyListeners = widget.monthlyListeners;
|
||||
// Extension artists don't need additional fetching
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority: widget data > cache > fetch
|
||||
// But always fetch if topTracks is missing (to get popular tracks)
|
||||
final cached = _ArtistCache.get(widget.artistId);
|
||||
|
||||
if (widget.albums != null) {
|
||||
_albums = widget.albums;
|
||||
_topTracks = widget.topTracks;
|
||||
_headerImageUrl = widget.headerImageUrl;
|
||||
_monthlyListeners = widget.monthlyListeners;
|
||||
|
||||
// If we have albums but no top tracks, fetch to get them
|
||||
if (_topTracks == null || _topTracks!.isEmpty) {
|
||||
_fetchDiscography();
|
||||
}
|
||||
} else if (cached != null) {
|
||||
_albums = cached.albums;
|
||||
_topTracks = cached.topTracks;
|
||||
_headerImageUrl = cached.headerImageUrl;
|
||||
_monthlyListeners = cached.monthlyListeners;
|
||||
|
||||
// If cache has no top tracks, fetch
|
||||
if (_topTracks == null || _topTracks!.isEmpty) {
|
||||
_fetchDiscography();
|
||||
}
|
||||
} else {
|
||||
_fetchDiscography();
|
||||
}
|
||||
}
|
||||
@@ -70,31 +155,60 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
setState(() => _isLoadingDiscography = true);
|
||||
try {
|
||||
List<ArtistAlbum> albums;
|
||||
List<Track>? topTracks;
|
||||
String? headerImage;
|
||||
int? listeners;
|
||||
|
||||
// Check if this is a Deezer artist ID (format: "deezer:123456")
|
||||
if (widget.artistId.startsWith('deezer:')) {
|
||||
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
|
||||
// ignore: avoid_print
|
||||
print('[ArtistScreen] Fetching from Deezer: $deezerArtistId');
|
||||
final metadata = await PlatformBridge.getDeezerMetadata('artist', deezerArtistId);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
} else {
|
||||
// Spotify artist - use fallback method
|
||||
// ignore: avoid_print
|
||||
print('[ArtistScreen] Fetching from Spotify with fallback: ${widget.artistId}');
|
||||
// Spotify artist - use extension handler via URL
|
||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
|
||||
if (result != null && result['artist'] != null) {
|
||||
final artistData = result['artist'] as Map<String, dynamic>;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
|
||||
// Parse top tracks if available
|
||||
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
if (topTracksList.isNotEmpty) {
|
||||
topTracks = topTracksList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
headerImage = artistData['header_image'] as String?;
|
||||
listeners = artistData['listeners'] as int?;
|
||||
} else {
|
||||
// Fallback to Spotify API metadata
|
||||
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
final albumsList = metadata['albums'] as List<dynamic>;
|
||||
albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||
}
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
_ArtistCache.set(widget.artistId, albums);
|
||||
// Store in cache (preserve existing values if new ones are null)
|
||||
final finalHeaderImage = headerImage ?? _headerImageUrl ?? widget.headerImageUrl;
|
||||
final finalListeners = listeners ?? _monthlyListeners ?? widget.monthlyListeners;
|
||||
|
||||
_ArtistCache.set(
|
||||
widget.artistId,
|
||||
albums: albums,
|
||||
topTracks: topTracks,
|
||||
headerImageUrl: finalHeaderImage,
|
||||
monthlyListeners: finalListeners,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = albums;
|
||||
_topTracks = topTracks;
|
||||
_headerImageUrl = finalHeaderImage;
|
||||
_monthlyListeners = finalListeners;
|
||||
_isLoadingDiscography = false;
|
||||
});
|
||||
}
|
||||
@@ -108,15 +222,41 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
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['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||
albumArtist: data['album_artist']?.toString(),
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
isrc: data['isrc']?.toString(),
|
||||
duration: (durationMs / 1000).round(),
|
||||
trackNumber: data['track_number'] as int?,
|
||||
discNumber: data['disc_number'] as int?,
|
||||
releaseDate: data['release_date']?.toString(),
|
||||
source: data['provider_id']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||
return ArtistAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
releaseDate: data['release_date'] as String? ?? '',
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: data['images'] as String?,
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
providerId: data['provider_id']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,43 +269,63 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, colorScheme),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
if (_isLoadingDiscography)
|
||||
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 (!_isLoadingDiscography && _error == null) ...[
|
||||
if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)),
|
||||
if (singles.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Singles & EPs', singles, colorScheme)),
|
||||
if (compilations.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Compilations', compilations, colorScheme)),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildHeader(context, colorScheme),
|
||||
if (_isLoadingDiscography)
|
||||
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 (!_isLoadingDiscography && _error == null) ...[
|
||||
// Popular tracks section
|
||||
if (_topTracks != null && _topTracks!.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildPopularSection(colorScheme)),
|
||||
// Discography sections
|
||||
if (albumsOnly.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistAlbums, albumsOnly, colorScheme)),
|
||||
if (singles.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistSingles, singles, colorScheme)),
|
||||
if (compilations.isNotEmpty)
|
||||
SliverToBoxAdapter(child: _buildAlbumSection(context.l10n.artistCompilations, compilations, colorScheme)),
|
||||
],
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||
final hasValidImage = widget.coverUrl != null &&
|
||||
widget.coverUrl!.isNotEmpty &&
|
||||
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
|
||||
/// Build Spotify-style header with full-width image and artist name overlay
|
||||
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
|
||||
// Use header image if available, otherwise fall back to cover URL
|
||||
// Prefer: fetched header > widget header > widget cover
|
||||
String? imageUrl = _headerImageUrl;
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
imageUrl = widget.headerImageUrl;
|
||||
}
|
||||
if (imageUrl == null || imageUrl.isEmpty) {
|
||||
imageUrl = widget.coverUrl;
|
||||
}
|
||||
|
||||
final hasValidImage = imageUrl != null &&
|
||||
imageUrl.isNotEmpty &&
|
||||
Uri.tryParse(imageUrl)?.hasAuthority == true;
|
||||
|
||||
// Format monthly listeners
|
||||
String? listenersText;
|
||||
final listeners = _monthlyListeners ?? widget.monthlyListeners;
|
||||
if (listeners != null && listeners > 0) {
|
||||
final formatter = NumberFormat.compact();
|
||||
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
|
||||
}
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
expandedHeight: 380,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
@@ -174,49 +334,84 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Background image - full width, no circular crop
|
||||
if (hasValidImage)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
||||
imageUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.topCenter, // Show top of image (faces)
|
||||
memCacheWidth: 800,
|
||||
placeholder: (context, url) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.person, size: 80, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
// Gradient overlay for text readability
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
||||
stops: const [0.0, 0.7, 1.0],
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.black.withValues(alpha: 0.3),
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
colorScheme.surface,
|
||||
],
|
||||
stops: const [0.0, 0.5, 0.75, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||
// Artist name and listeners at bottom
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 4,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 280,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
if (listenersText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
listenersText,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.8),
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -224,44 +419,280 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
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), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.4),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.artistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||
const SizedBox(height: 8),
|
||||
if (_albums != null)
|
||||
Container(
|
||||
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.album, size: 14, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${_albums!.length} releases', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
/// Build Popular tracks section like Spotify
|
||||
Widget _buildPopularSection(ColorScheme colorScheme) {
|
||||
if (_topTracks == null || _topTracks!.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
// Show max 5 tracks
|
||||
final tracks = _topTracks!.take(5).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Text(
|
||||
context.l10n.artistPopular,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
...tracks.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final track = entry.value;
|
||||
return _buildPopularTrackItem(index + 1, track, colorScheme);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a single popular track item with dynamic download status
|
||||
Widget _buildPopularTrackItem(int rank, Track track, ColorScheme colorScheme) {
|
||||
// Watch download queue for this track's status
|
||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
||||
}));
|
||||
|
||||
// Check if track is in history (already downloaded before)
|
||||
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||
return state.isDownloaded(track.id);
|
||||
}));
|
||||
|
||||
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;
|
||||
|
||||
// Show as downloaded if in queue completed OR in history
|
||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Rank number
|
||||
SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Album art
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: track.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: track.coverUrl!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 96,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (track.albumName.isNotEmpty)
|
||||
Text(
|
||||
track.albumName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Download button with status
|
||||
_buildPopularDownloadButton(
|
||||
track: track,
|
||||
colorScheme: colorScheme,
|
||||
isQueued: isQueued,
|
||||
isDownloading: isDownloading,
|
||||
isFinalizing: isFinalizing,
|
||||
showAsDownloaded: showAsDownloaded,
|
||||
isInHistory: isInHistory,
|
||||
progress: progress,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle tap on popular track item
|
||||
void _handlePopularTrackTap(Track track, {required bool isQueued, required bool isInHistory}) async {
|
||||
if (isQueued) return;
|
||||
|
||||
if (isInHistory) {
|
||||
final historyItem = ref.read(downloadHistoryProvider.notifier).getBySpotifyId(track.id);
|
||||
if (historyItem != null) {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))),
|
||||
);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
ref.read(downloadHistoryProvider.notifier).removeBySpotifyId(track.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_downloadTrack(track);
|
||||
}
|
||||
|
||||
/// Build download button with status indicator for popular tracks
|
||||
Widget _buildPopularDownloadButton({
|
||||
required Track track,
|
||||
required ColorScheme colorScheme,
|
||||
required bool isQueued,
|
||||
required bool isDownloading,
|
||||
required bool isFinalizing,
|
||||
required bool showAsDownloaded,
|
||||
required bool isInHistory,
|
||||
required double progress,
|
||||
}) {
|
||||
const double size = 40.0;
|
||||
const double iconSize = 20.0;
|
||||
|
||||
if (showAsDownloaded) {
|
||||
return GestureDetector(
|
||||
onTap: () => _handlePopularTrackTap(track, isQueued: isQueued, isInHistory: isInHistory),
|
||||
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(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: colorScheme.tertiary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
Icon(Icons.edit_note, color: colorScheme.tertiary, size: 14),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (isDownloading) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: progress > 0 ? progress : null,
|
||||
strokeWidth: 2.5,
|
||||
color: colorScheme.primary,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
if (progress > 0)
|
||||
Text(
|
||||
'${(progress * 100).toInt()}',
|
||||
style: TextStyle(fontSize: 9, 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),
|
||||
);
|
||||
} else {
|
||||
return GestureDetector(
|
||||
onTap: () => _downloadTrack(track),
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.download, color: colorScheme.onSecondaryContainer, size: iconSize),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadTrack(Track track) {
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarAddedToQueue(track.name)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -271,24 +702,26 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.album, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('$title (${albums.length})', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.primary)),
|
||||
],
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||
child: Text(
|
||||
'$title (${albums.length})',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 210,
|
||||
height: 220,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
return KeyedSubtree(key: ValueKey(album.id), child: _buildAlbumCard(album, colorScheme));
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(album.id),
|
||||
child: _buildAlbumCard(album, colorScheme),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -301,62 +734,90 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
onTap: () => _navigateToAlbum(album),
|
||||
child: Container(
|
||||
width: 140,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: album.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: album.coverUrl!, width: 124, height: 124, fit: BoxFit.cover, memCacheWidth: 248)
|
||||
: Container(width: 124, height: 124, color: colorScheme.surfaceContainerHighest, child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(album.name, style: Theme.of(context).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600), maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
const Spacer(),
|
||||
Text(
|
||||
album.totalTracks > 0
|
||||
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} • ${album.totalTracks} tracks'
|
||||
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant, fontSize: 11),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Album cover
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: album.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
width: 140,
|
||||
height: 140,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 280,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Album name
|
||||
Text(
|
||||
album.name,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Year and track count
|
||||
Text(
|
||||
album.totalTracks > 0
|
||||
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
|
||||
: album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToAlbum(ArtistAlbum album) {
|
||||
// Navigate immediately with data from artist discography, fetch tracks in AlbumScreen
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => AlbumScreen(
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
// tracks: null - will be fetched in AlbumScreen
|
||||
),
|
||||
));
|
||||
|
||||
if (album.providerId != null && album.providerId!.isNotEmpty) {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => ExtensionAlbumScreen(
|
||||
extensionId: album.providerId!,
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
),
|
||||
));
|
||||
} else {
|
||||
Navigator.push(context, MaterialPageRoute(
|
||||
builder: (context) => AlbumScreen(
|
||||
albumId: album.id,
|
||||
albumName: album.name,
|
||||
coverUrl: album.coverUrl,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Build error widget with special handling for rate limit (429)
|
||||
Widget _buildErrorWidget(String error, ColorScheme colorScheme) {
|
||||
final isRateLimit = error.contains('429') ||
|
||||
error.toLowerCase().contains('rate limit') ||
|
||||
@@ -366,7 +827,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
@@ -378,7 +839,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Rate Limited',
|
||||
context.l10n.errorRateLimited,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -386,7 +847,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Too many requests. Please wait a moment and try again.',
|
||||
context.l10n.errorRateLimitedMessage,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
@@ -401,11 +862,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// Default error display
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
|
||||
@@ -4,6 +4,8 @@ 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/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/mime_utils.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||
|
||||
@@ -83,19 +85,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete Selected'),
|
||||
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
|
||||
title: Text(context.l10n.downloadedAlbumDeleteSelected),
|
||||
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text('Delete'),
|
||||
child: Text(context.l10n.dialogDelete),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -124,7 +126,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
|
||||
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -132,11 +134,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
||||
Future<void> _openFile(String filePath) async {
|
||||
try {
|
||||
await OpenFilex.open(filePath);
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
await OpenFilex.open(filePath, type: mimeType);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open file: $e')),
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -321,7 +324,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
children: [
|
||||
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} downloaded', 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -374,13 +377,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Tracks', 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,
|
||||
icon: const Icon(Icons.checklist, size: 18),
|
||||
label: const Text('Select'),
|
||||
label: Text(context.l10n.actionSelect),
|
||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
),
|
||||
],
|
||||
@@ -521,11 +524,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected ? 'All tracks selected' : 'Tap tracks to select',
|
||||
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
@@ -540,7 +543,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
}
|
||||
},
|
||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||
label: Text(allSelected ? 'Deselect' : 'Select All'),
|
||||
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||
),
|
||||
],
|
||||
@@ -553,8 +556,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
|
||||
: 'Select tracks to delete',
|
||||
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
|
||||
: context.l10n.downloadedAlbumSelectToDelete,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
||||
|
||||
@@ -6,6 +6,8 @@ import 'package:cached_network_image/cached_network_image.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});
|
||||
@@ -267,6 +269,23 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
|
||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
||||
final track = ref.watch(trackProvider).tracks[index];
|
||||
final isCollection = track.isCollection;
|
||||
|
||||
// Determine subtitle text based on item type
|
||||
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(
|
||||
@@ -285,22 +304,87 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
isCollection ? Icons.album : Icons.music_note,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text(
|
||||
track.artistName,
|
||||
subtitleText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: Text(
|
||||
_formatDuration(track.duration),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
onTap: () => _downloadTrack(index),
|
||||
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(index),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openCollection(Track track) async {
|
||||
// Get the extension ID from the track source
|
||||
final extensionId = track.source;
|
||||
if (extensionId == null) return;
|
||||
|
||||
// Fetch album/playlist tracks using the extension
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
||||
@@ -77,7 +79,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
// Show snackbar
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Loading shared link...')),
|
||||
SnackBar(content: Text(context.l10n.loadingSharedLink)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,6 +124,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
void _onPageChanged(int index) {
|
||||
if (_currentIndex != index) {
|
||||
setState(() => _currentIndex = index);
|
||||
// Unfocus any text field when switching tabs to prevent keyboard from appearing
|
||||
// Use primaryFocus for more aggressive unfocus that works with keep-alive widgets
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +137,15 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
if (isKeyboardVisible) {
|
||||
FocusScope.of(context).unfocus();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
// If on Home tab and showing recent access mode, exit it
|
||||
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
// Also unfocus search bar when exiting recent access mode
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,9 +173,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
} else {
|
||||
_lastBackPress = now;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Press back again to exit'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.pressBackAgainToExit),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
@@ -174,6 +187,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||
final trackState = ref.watch(trackProvider);
|
||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
||||
final storeUpdatesCount = ref.watch(storeProvider.select((s) => s.updatesAvailableCount));
|
||||
|
||||
// Check if keyboard is visible (bottom inset > 0 means keyboard is showing)
|
||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
@@ -185,21 +199,27 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
!trackState.hasSearchText &&
|
||||
!trackState.hasContent &&
|
||||
!trackState.isLoading &&
|
||||
!trackState.isShowingRecentAccess &&
|
||||
!isKeyboardVisible;
|
||||
|
||||
// Build tabs and destinations based on settings
|
||||
final tabs = <Widget>[
|
||||
const HomeTab(),
|
||||
const QueueTab(),
|
||||
QueueTab(
|
||||
parentPageController: _pageController,
|
||||
parentPageIndex: 1,
|
||||
nextPageIndex: showStore ? 2 : 3,
|
||||
),
|
||||
if (showStore) const StoreTab(),
|
||||
const SettingsTab(),
|
||||
];
|
||||
|
||||
final l10n = context.l10n;
|
||||
final destinations = <NavigationDestination>[
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: 'Home',
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
selectedIcon: const Icon(Icons.home),
|
||||
label: l10n.navHome,
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
@@ -212,18 +232,26 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
label: Text('$queueState'),
|
||||
child: const Icon(Icons.history),
|
||||
),
|
||||
label: 'History',
|
||||
label: l10n.navHistory,
|
||||
),
|
||||
if (showStore)
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.store_outlined),
|
||||
selectedIcon: Icon(Icons.store),
|
||||
label: 'Store',
|
||||
NavigationDestination(
|
||||
icon: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store_outlined),
|
||||
),
|
||||
selectedIcon: Badge(
|
||||
isLabelVisible: storeUpdatesCount > 0,
|
||||
label: Text('$storeUpdatesCount'),
|
||||
child: const Icon(Icons.store),
|
||||
),
|
||||
label: l10n.navStore,
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.settings_outlined),
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: 'Settings',
|
||||
NavigationDestination(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
selectedIcon: const Icon(Icons.settings),
|
||||
label: l10n.navSettings,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -254,7 +282,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
||||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
physics: const ClampingScrollPhysics(),
|
||||
children: tabs,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
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/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';
|
||||
@@ -114,7 +115,7 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} tracks', 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -122,7 +123,7 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
FilledButton.icon(
|
||||
onPressed: () => _downloadAll(context, ref),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text('Download All (${tracks.length})'),
|
||||
label: Text(context.l10n.downloadAllCount(tracks.length)),
|
||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||
),
|
||||
],
|
||||
@@ -141,7 +142,7 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('Tracks', 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -176,12 +177,12 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
coverUrl: track.coverUrl,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
|
||||
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('Added "${track.name}" to queue')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,12 +196,12 @@ class PlaylistScreen extends ConsumerWidget {
|
||||
artistName: playlistName,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
|
||||
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('Added ${tracks.length} tracks to queue')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,7 +265,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
||||
final fileExists = await File(historyItem.filePath).exists();
|
||||
if (fileExists) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('"${track.name}" already downloaded')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAlreadyDownloaded(track.name))));
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
|
||||
@@ -14,19 +15,19 @@ class QueueScreen extends ConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Download Queue'),
|
||||
title: Text(context.l10n.queueTitle),
|
||||
actions: [
|
||||
if (queueState.items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_sweep),
|
||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
||||
tooltip: 'Clear completed',
|
||||
tooltip: context.l10n.queueClearCompleted,
|
||||
),
|
||||
if (queueState.items.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear_all),
|
||||
onPressed: () => _showClearAllDialog(context, ref),
|
||||
tooltip: 'Clear all',
|
||||
tooltip: context.l10n.queueClearAll,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -51,14 +52,14 @@ class QueueScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No downloads in queue',
|
||||
context.l10n.queueEmpty,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add tracks from the home screen',
|
||||
context.l10n.queueEmptySubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
@@ -177,7 +178,7 @@ class QueueScreen extends ConsumerWidget {
|
||||
children: [
|
||||
Icon(Icons.error, color: colorScheme.error),
|
||||
const SizedBox(width: 8),
|
||||
const Text('Download Failed'),
|
||||
Text(context.l10n.queueDownloadFailed),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
@@ -185,10 +186,10 @@ class QueueScreen extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('Artist: ${item.track.artistName}'),
|
||||
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Error:', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(context.l10n.queueErrorLabel, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -197,7 +198,7 @@ class QueueScreen extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
item.error ?? 'Unknown error',
|
||||
item.error ?? context.l10n.queueUnknownError,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
@@ -211,7 +212,7 @@ class QueueScreen extends ConsumerWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Close'),
|
||||
child: Text(context.l10n.dialogClose),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -223,19 +224,19 @@ class QueueScreen extends ConsumerWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear All'),
|
||||
content: const Text('Are you sure you want to clear all downloads?'),
|
||||
title: Text(context.l10n.queueClearAll),
|
||||
content: Text(context.l10n.queueClearAllMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
||||
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class AboutPage extends StatelessWidget {
|
||||
@@ -13,45 +14,45 @@ class AboutPage extends StatelessWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
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);
|
||||
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||
// When expanded (expandRatio=1): left=24 for normal padding
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'About',
|
||||
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);
|
||||
// When collapsed (expandRatio=0): left=56 to avoid back button
|
||||
// When expanded (expandRatio=1): left=24 for normal padding
|
||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// App header card with logo and description
|
||||
SliverToBoxAdapter(
|
||||
@@ -62,27 +63,27 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Contributors section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Contributors'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ContributorItem(
|
||||
name: AppInfo.mobileAuthor,
|
||||
description: 'Mobile version developer',
|
||||
description: context.l10n.aboutMobileDeveloper,
|
||||
githubUsername: AppInfo.mobileAuthor,
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
name: AppInfo.originalAuthor,
|
||||
description: 'Creator of the original SpotiFLAC',
|
||||
description: context.l10n.aboutOriginalCreator,
|
||||
githubUsername: AppInfo.originalAuthor,
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
name: 'Amonoman',
|
||||
description: 'The talented artist who created our beautiful app logo!',
|
||||
description: context.l10n.aboutLogoArtist,
|
||||
githubUsername: 'Amonoman',
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -91,35 +92,35 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Special Thanks section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Special Thanks'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_ContributorItem(
|
||||
name: 'uimaxbai',
|
||||
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
|
||||
githubUsername: 'uimaxbai',
|
||||
name: 'binimum',
|
||||
description: context.l10n.aboutBinimumDesc,
|
||||
githubUsername: 'binimum',
|
||||
showDivider: true,
|
||||
),
|
||||
_ContributorItem(
|
||||
name: 'sachinsenal0x64',
|
||||
description: 'The original HiFi project creator. The foundation of Tidal integration!',
|
||||
description: context.l10n.aboutSachinsenalDesc,
|
||||
githubUsername: 'sachinsenal0x64',
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.cloud_outlined,
|
||||
title: 'DoubleDouble',
|
||||
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
||||
title: context.l10n.aboutDoubleDouble,
|
||||
subtitle: context.l10n.aboutDoubleDoubleDesc,
|
||||
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||
showDivider: true,
|
||||
),
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.music_note_outlined,
|
||||
title: 'DAB Music',
|
||||
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
||||
title: context.l10n.aboutDabMusic,
|
||||
subtitle: context.l10n.aboutDabMusicDesc,
|
||||
onTap: () => _launchUrl('https://dabmusic.xyz'),
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -128,37 +129,37 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Links section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Links'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.phone_android,
|
||||
title: 'Mobile source code',
|
||||
title: context.l10n.aboutMobileSource,
|
||||
subtitle: 'github.com/${AppInfo.githubRepo}',
|
||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.computer,
|
||||
title: 'PC source code',
|
||||
title: context.l10n.aboutPCSource,
|
||||
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
|
||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.bug_report_outlined,
|
||||
title: 'Report an issue',
|
||||
subtitle: 'Report any problems you encounter',
|
||||
title: context.l10n.aboutReportIssue,
|
||||
subtitle: context.l10n.aboutReportIssueSubtitle,
|
||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||
showDivider: true,
|
||||
),
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.lightbulb_outline,
|
||||
title: 'Feature request',
|
||||
subtitle: 'Suggest new features for the app',
|
||||
title: context.l10n.aboutFeatureRequest,
|
||||
subtitle: context.l10n.aboutFeatureRequestSubtitle,
|
||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -167,16 +168,16 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Support section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Support'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.coffee_outlined,
|
||||
title: 'Buy me a coffee',
|
||||
subtitle: 'Support development on Ko-fi',
|
||||
title: context.l10n.aboutBuyMeCoffee,
|
||||
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
|
||||
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -185,15 +186,15 @@ class AboutPage extends StatelessWidget {
|
||||
),
|
||||
|
||||
// App info section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'App'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.aboutApp),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
_AboutSettingsItem(
|
||||
icon: Icons.info_outline,
|
||||
title: 'Version',
|
||||
title: context.l10n.aboutVersion,
|
||||
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -220,7 +221,7 @@ class AboutPage extends StatelessWidget {
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -300,7 +301,7 @@ class _AppHeaderCard extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
// Description
|
||||
Text(
|
||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
|
||||
context.l10n.aboutAppDescription,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
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/widgets/settings_group.dart';
|
||||
@@ -15,27 +17,27 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
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: _AppBarTitle(
|
||||
title: 'Appearance',
|
||||
topPadding: topPadding,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
// Preview Section
|
||||
SliverToBoxAdapter(
|
||||
@@ -49,8 +51,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Color section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Color'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionColor),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
@@ -58,8 +60,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.wallpaper,
|
||||
title: 'Dynamic Color',
|
||||
subtitle: 'Use colors from your wallpaper',
|
||||
title: context.l10n.appearanceDynamicColor,
|
||||
subtitle: context.l10n.appearanceDynamicColorSubtitle,
|
||||
value: themeSettings.useDynamicColor,
|
||||
onChanged: (value) => ref
|
||||
.read(themeProvider.notifier)
|
||||
@@ -82,8 +84,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Theme section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Theme'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -96,8 +98,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
if (Theme.of(context).brightness == Brightness.dark)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.brightness_2,
|
||||
title: 'AMOLED Dark',
|
||||
subtitle: 'Pure black background',
|
||||
title: context.l10n.appearanceAmoledDark,
|
||||
subtitle: context.l10n.appearanceAmoledDarkSubtitle,
|
||||
value: themeSettings.useAmoled,
|
||||
onChanged: (value) =>
|
||||
ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||
@@ -107,9 +109,26 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Language section
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_LanguageSelector(
|
||||
currentLocale: settings.locale,
|
||||
onChanged: (locale) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setLocale(locale),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Layout section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Layout'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -283,7 +302,7 @@ class _ThemePreviewCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
isDark ? 'Dark Mode' : 'Light Mode',
|
||||
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
@@ -451,21 +470,21 @@ class _ThemeModeSelector extends StatelessWidget {
|
||||
children: [
|
||||
_ThemeModeChip(
|
||||
icon: Icons.brightness_auto,
|
||||
label: 'System',
|
||||
label: context.l10n.appearanceThemeSystem,
|
||||
isSelected: currentMode == ThemeMode.system,
|
||||
onTap: () => onChanged(ThemeMode.system),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ThemeModeChip(
|
||||
icon: Icons.light_mode,
|
||||
label: 'Light',
|
||||
label: context.l10n.appearanceThemeLight,
|
||||
isSelected: currentMode == ThemeMode.light,
|
||||
onTap: () => onChanged(ThemeMode.light),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ThemeModeChip(
|
||||
icon: Icons.dark_mode,
|
||||
label: 'Dark',
|
||||
label: context.l10n.appearanceThemeDark,
|
||||
isSelected: currentMode == ThemeMode.dark,
|
||||
onTap: () => onChanged(ThemeMode.dark),
|
||||
),
|
||||
@@ -575,7 +594,7 @@ class _HistoryViewSelector extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||
child: Text(
|
||||
'History View',
|
||||
context.l10n.appearanceHistoryView,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -585,14 +604,14 @@ class _HistoryViewSelector extends StatelessWidget {
|
||||
children: [
|
||||
_ViewModeChip(
|
||||
icon: Icons.view_list,
|
||||
label: 'List',
|
||||
label: context.l10n.appearanceHistoryViewList,
|
||||
isSelected: currentMode == 'list',
|
||||
onTap: () => onChanged('list'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ViewModeChip(
|
||||
icon: Icons.grid_view,
|
||||
label: 'Grid',
|
||||
label: context.l10n.appearanceHistoryViewGrid,
|
||||
isSelected: currentMode == 'grid',
|
||||
onTap: () => onChanged('grid'),
|
||||
),
|
||||
@@ -682,3 +701,132 @@ class _ViewModeChip extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LanguageSelector extends StatelessWidget {
|
||||
final String currentLocale;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _LanguageSelector({
|
||||
required this.currentLocale,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
// All available languages (code, displayName, icon)
|
||||
static const _allLanguages = [
|
||||
('system', 'System Default', Icons.phone_android),
|
||||
('en', 'English', Icons.language),
|
||||
('id', 'Bahasa Indonesia', Icons.language),
|
||||
('de', 'Deutsch', Icons.language),
|
||||
('es', 'Español', Icons.language),
|
||||
('fr', 'Français', Icons.language),
|
||||
('hi', 'हिन्दी', Icons.language),
|
||||
('ja', '日本語', Icons.language),
|
||||
('ko', '한국어', Icons.language),
|
||||
('nl', 'Nederlands', Icons.language),
|
||||
('pt', 'Português', Icons.language),
|
||||
('ru', 'Русский', Icons.language),
|
||||
('zh', '简体中文', Icons.language),
|
||||
('zh_TW', '繁體中文', Icons.language),
|
||||
];
|
||||
|
||||
/// Get only languages that meet the translation threshold.
|
||||
/// Uses filteredLocaleCodes from supported_locales.dart (generated file).
|
||||
List<(String, String, IconData)> get _languages {
|
||||
return _allLanguages.where((lang) {
|
||||
// Always include 'system' option
|
||||
if (lang.$1 == 'system') return true;
|
||||
// Only include languages in the filtered set
|
||||
return filteredLocaleCodes.contains(lang.$1);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _getLanguageName(String code) {
|
||||
// Search in all languages (not just filtered) for display name fallback
|
||||
for (final lang in _allLanguages) {
|
||||
if (lang.$1 == code) return lang.$2;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
Icons.language,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(context.l10n.appearanceLanguage),
|
||||
subtitle: Text(_getLanguageName(currentLocale)),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onTap: () => _showLanguagePicker(context),
|
||||
);
|
||||
}
|
||||
|
||||
void _showLanguagePicker(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
context.l10n.appearanceLanguage,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _languages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final lang = _languages[index];
|
||||
final isSelected = currentLocale == lang.$1;
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
lang.$3,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
title: Text(
|
||||
lang.$2,
|
||||
style: TextStyle(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurface,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
trailing: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.primary)
|
||||
: null,
|
||||
onTap: () {
|
||||
onChanged(lang.$1);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.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/widgets/settings_group.dart';
|
||||
@@ -23,53 +24,53 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
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(
|
||||
'Download',
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Service section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Service'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -85,17 +86,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Quality section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Audio Quality'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.tune,
|
||||
title: 'Ask Before Download',
|
||||
title: context.l10n.downloadAskBeforeDownload,
|
||||
subtitle: isBuiltInService
|
||||
? 'Choose quality for each download'
|
||||
? context.l10n.downloadAskQualitySubtitle
|
||||
: 'Select a built-in service to enable',
|
||||
value: settings.askQualityBeforeDownload,
|
||||
// Not selected visually if extension is active
|
||||
@@ -106,24 +107,24 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||
_QualityOption(
|
||||
title: 'FLAC Lossless',
|
||||
subtitle: '16-bit / 44.1kHz',
|
||||
title: context.l10n.qualityFlacLossless,
|
||||
subtitle: context.l10n.qualityFlacLosslessSubtitle,
|
||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('LOSSLESS'),
|
||||
),
|
||||
_QualityOption(
|
||||
title: 'Hi-Res FLAC',
|
||||
subtitle: '24-bit / up to 96kHz',
|
||||
title: context.l10n.qualityHiResFlac,
|
||||
subtitle: context.l10n.qualityHiResFlacSubtitle,
|
||||
isSelected: settings.audioQuality == 'HI_RES',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAudioQuality('HI_RES'),
|
||||
),
|
||||
_QualityOption(
|
||||
title: 'Hi-Res FLAC Max',
|
||||
subtitle: '24-bit / up to 192kHz',
|
||||
title: context.l10n.qualityHiResFlacMax,
|
||||
subtitle: context.l10n.qualityHiResFlacMaxSubtitle,
|
||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||
onTap: () => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -159,15 +160,15 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// File settings section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'File Settings'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.text_fields,
|
||||
title: 'Filename Format',
|
||||
title: context.l10n.downloadFilenameFormat,
|
||||
subtitle: settings.filenameFormat,
|
||||
onTap: () => _showFormatEditor(
|
||||
context,
|
||||
@@ -177,17 +178,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: 'Download Directory',
|
||||
title: context.l10n.downloadDirectory,
|
||||
subtitle: settings.downloadDirectory.isEmpty
|
||||
? (Platform.isIOS
|
||||
? 'App Documents Folder'
|
||||
? context.l10n.setupAppDocumentsFolder
|
||||
: 'Music/SpotiFLAC')
|
||||
: settings.downloadDirectory,
|
||||
onTap: () => _pickDirectory(context, ref),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: 'Separate Singles Folder',
|
||||
title: context.l10n.downloadSeparateSinglesFolder,
|
||||
subtitle: settings.separateSingles
|
||||
? 'Albums/ and Singles/ folders'
|
||||
: 'All files in same structure',
|
||||
@@ -196,10 +197,21 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
.read(settingsProvider.notifier)
|
||||
.setSeparateSingles(value),
|
||||
),
|
||||
if (settings.separateSingles)
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: context.l10n.downloadAlbumFolderStructure,
|
||||
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
|
||||
onTap: () => _showAlbumFolderStructurePicker(
|
||||
context,
|
||||
ref,
|
||||
settings.albumFolderStructure,
|
||||
),
|
||||
),
|
||||
if (!settings.separateSingles)
|
||||
SettingsItem(
|
||||
icon: Icons.create_new_folder_outlined,
|
||||
title: 'Folder Organization',
|
||||
title: context.l10n.downloadFolderOrganization,
|
||||
subtitle: _getFolderOrganizationLabel(
|
||||
settings.folderOrganization,
|
||||
),
|
||||
@@ -221,6 +233,72 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
String _getAlbumFolderStructureLabel(String structure) {
|
||||
switch (structure) {
|
||||
case 'album_only':
|
||||
return 'Albums/Album Name/';
|
||||
case 'artist_year_album':
|
||||
return 'Albums/Artist/[Year] Album/';
|
||||
case 'year_album':
|
||||
return 'Albums/[Year] Album/';
|
||||
default:
|
||||
return 'Albums/Artist/Album Name/';
|
||||
}
|
||||
}
|
||||
|
||||
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
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,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
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,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_year_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
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,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
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,
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('year_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||
final controller = TextEditingController(text: current);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -290,7 +368,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Filename Format',
|
||||
context.l10n.filenameFormat,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -356,7 +434,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
@@ -364,7 +442,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -383,7 +461,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: const Text('Save Format'),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -427,7 +505,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Download Location',
|
||||
context.l10n.setupDownloadLocationTitle,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
@@ -436,7 +514,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||
context.l10n.setupDownloadLocationIosMessage,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -444,8 +522,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||
title: const Text('App Documents Folder'),
|
||||
subtitle: const Text('Recommended - accessible via Files app'),
|
||||
title: Text(context.l10n.setupAppDocumentsFolder),
|
||||
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
|
||||
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||
onTap: () async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
@@ -457,8 +535,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Choose from Files'),
|
||||
subtitle: const Text('Select iCloud or other location'),
|
||||
title: Text(context.l10n.setupChooseFromFiles),
|
||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
// Note: iOS requires folder to have at least one file to be selectable
|
||||
@@ -488,7 +566,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||
context.l10n.setupIosEmptyFolderWarning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
@@ -512,7 +590,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
case 'album':
|
||||
return 'By Album';
|
||||
case 'artist_album':
|
||||
return 'By Artist & Album';
|
||||
return 'Artist/Album';
|
||||
default:
|
||||
return 'None';
|
||||
}
|
||||
@@ -527,74 +605,80 @@ class DownloadSettingsPage extends ConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.7,
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Folder Organization',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Organize downloaded files into folders',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Folder Organization',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
),
|
||||
_FolderOption(
|
||||
title: 'None',
|
||||
subtitle: 'All files in download folder',
|
||||
example: 'SpotiFLAC/Track.flac',
|
||||
isSelected: current == 'none',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_FolderOption(
|
||||
title: 'By Artist',
|
||||
subtitle: 'Separate folder for each artist',
|
||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||
isSelected: current == 'artist',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_FolderOption(
|
||||
title: 'By Album',
|
||||
subtitle: 'Separate folder for each album',
|
||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||
isSelected: current == 'album',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_FolderOption(
|
||||
title: 'By Artist & Album',
|
||||
subtitle: 'Nested folders for artist and album',
|
||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||
isSelected: current == 'artist_album',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
context.l10n.folderOrganizationDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
_FolderOption(
|
||||
title: context.l10n.folderOrganizationNone,
|
||||
subtitle: context.l10n.folderOrganizationNoneSubtitle,
|
||||
example: 'SpotiFLAC/Track.flac',
|
||||
isSelected: current == 'none',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('none');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_FolderOption(
|
||||
title: context.l10n.folderOrganizationByArtist,
|
||||
subtitle: context.l10n.folderOrganizationByArtistSubtitle,
|
||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||
isSelected: current == 'artist',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_FolderOption(
|
||||
title: context.l10n.folderOrganizationByAlbum,
|
||||
subtitle: context.l10n.folderOrganizationByAlbumSubtitle,
|
||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||
isSelected: current == 'album',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
_FolderOption(
|
||||
title: context.l10n.folderOrganizationByArtistAlbum,
|
||||
subtitle: context.l10n.folderOrganizationByArtistAlbumSubtitle,
|
||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||
isSelected: current == 'artist_album',
|
||||
onTap: () {
|
||||
ref.read(settingsProvider.notifier).setFolderOrganization('artist_album');
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
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/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
@@ -56,11 +57,13 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final hasError = extension.status == 'error';
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
@@ -184,11 +187,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_InfoRow(label: 'Author', value: extension.author),
|
||||
_InfoRow(label: 'ID', value: extension.id),
|
||||
_InfoRow(label: context.l10n.extensionAuthor, value: extension.author),
|
||||
_InfoRow(label: context.l10n.extensionId, value: extension.id),
|
||||
_InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''),
|
||||
if (hasError && extension.errorMessage != null)
|
||||
_InfoRow(
|
||||
label: 'Error',
|
||||
label: context.l10n.extensionError,
|
||||
value: extension.errorMessage!,
|
||||
isError: true,
|
||||
),
|
||||
@@ -199,42 +203,50 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
),
|
||||
|
||||
// Capabilities
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Capabilities'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_CapabilityItem(
|
||||
icon: Icons.search,
|
||||
title: 'Metadata Provider',
|
||||
title: context.l10n.extensionMetadataProvider,
|
||||
enabled: extension.hasMetadataProvider,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.download,
|
||||
title: 'Download Provider',
|
||||
title: context.l10n.extensionDownloadProvider,
|
||||
enabled: extension.hasDownloadProvider,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.manage_search,
|
||||
title: 'Custom Search',
|
||||
title: context.l10n.extensionsSearchProvider,
|
||||
enabled: extension.hasCustomSearch,
|
||||
subtitle: extension.searchBehavior?.placeholder,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.compare_arrows,
|
||||
title: 'Custom Track Matching',
|
||||
title: context.l10n.extensionCustomTrackMatching,
|
||||
enabled: extension.hasCustomMatching,
|
||||
subtitle: extension.trackMatching?.strategy != null
|
||||
? 'Strategy: ${extension.trackMatching!.strategy}'
|
||||
? context.l10n.extensionStrategy(extension.trackMatching!.strategy!)
|
||||
: null,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.auto_fix_high,
|
||||
title: 'Post-Processing',
|
||||
title: context.l10n.extensionPostProcessing,
|
||||
enabled: extension.hasPostProcessing,
|
||||
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
|
||||
? '${extension.postProcessing!.hooks.length} hook(s) available'
|
||||
? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length)
|
||||
: null,
|
||||
),
|
||||
_CapabilityItem(
|
||||
icon: Icons.link,
|
||||
title: context.l10n.extensionUrlHandler,
|
||||
enabled: extension.hasURLHandler,
|
||||
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
|
||||
? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length)
|
||||
: null,
|
||||
showDivider: false,
|
||||
),
|
||||
@@ -242,26 +254,47 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
),
|
||||
),
|
||||
|
||||
// Search Provider Section (if extension has custom search)
|
||||
if (extension.hasCustomSearch) ...[
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Search Provider'),
|
||||
|
||||
|
||||
// URL Handler Section (if extension handles URLs)
|
||||
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
_SearchProviderInfo(
|
||||
extension: extension,
|
||||
_URLHandlerInfo(
|
||||
patterns: extension.urlHandler!.patterns,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Quality Options Section (for download providers)
|
||||
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: extension.qualityOptions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final quality = entry.value;
|
||||
return _QualityOptionItem(
|
||||
quality: quality,
|
||||
showDivider: index < extension.qualityOptions.length - 1,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// Post-Processing Hooks (if available)
|
||||
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Post-Processing Hooks'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -279,8 +312,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
|
||||
// Permissions
|
||||
if (extension.permissions.isNotEmpty) ...[
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Permissions'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -298,8 +331,8 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
|
||||
// Settings
|
||||
if (extension.settings.isNotEmpty) ...[
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Settings'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
|
||||
),
|
||||
if (_isLoadingSettings)
|
||||
const SliverToBoxAdapter(
|
||||
@@ -332,7 +365,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _confirmRemove(context),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Remove Extension'),
|
||||
label: Text(context.l10n.extensionRemoveButton),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: colorScheme.error,
|
||||
side: BorderSide(color: colorScheme.error),
|
||||
@@ -348,6 +381,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,22 +399,21 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove Extension'),
|
||||
content: const Text(
|
||||
'Are you sure you want to remove this extension? '
|
||||
'This action cannot be undone.',
|
||||
title: Text(context.l10n.dialogRemoveExtension),
|
||||
content: Text(
|
||||
context.l10n.dialogRemoveExtensionMessage,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
child: const Text('Remove'),
|
||||
child: Text(context.l10n.dialogRemove),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -692,7 +725,7 @@ class _SettingItem extends StatelessWidget {
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
@@ -702,7 +735,7 @@ class _SettingItem extends StatelessWidget {
|
||||
onChanged(newValue);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -817,17 +850,18 @@ class _PostProcessingHookItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchProviderInfo extends StatelessWidget {
|
||||
final Extension extension;
|
||||
|
||||
const _SearchProviderInfo({
|
||||
required this.extension,
|
||||
|
||||
class _URLHandlerInfo extends StatelessWidget {
|
||||
final List<String> patterns;
|
||||
|
||||
const _URLHandlerInfo({
|
||||
required this.patterns,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final searchBehavior = extension.searchBehavior;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -840,12 +874,12 @@ class _SearchProviderInfo extends StatelessWidget {
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.manage_search,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
Icons.link,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
@@ -855,14 +889,14 @@ class _SearchProviderInfo extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Custom Search Available',
|
||||
'Custom URL Handling',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'This extension provides its own search functionality',
|
||||
'This extension can handle links from these sites',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -873,25 +907,38 @@ class _SearchProviderInfo extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Search placeholder info
|
||||
if (searchBehavior?.placeholder != null) ...[
|
||||
_InfoTile(
|
||||
icon: Icons.text_fields,
|
||||
label: 'Search Hint',
|
||||
value: searchBehavior!.placeholder!,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Primary search info
|
||||
_InfoTile(
|
||||
icon: searchBehavior?.primary == true ? Icons.star : Icons.star_border,
|
||||
label: 'Priority',
|
||||
value: searchBehavior?.primary == true
|
||||
? 'Primary search provider'
|
||||
: 'Secondary search provider',
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: patterns.map((pattern) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.language,
|
||||
size: 16,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
pattern,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Usage instructions
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
@@ -908,7 +955,7 @@ class _SearchProviderInfo extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'To use this search provider, tap the search bar on the Home tab and select "${extension.displayName}" from the provider chips.',
|
||||
'Share links from these sites to SpotiFLAC and this extension will handle them.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -923,44 +970,95 @@ class _SearchProviderInfo extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
class _QualityOptionItem extends StatelessWidget {
|
||||
final QualityOption quality;
|
||||
final bool showDivider;
|
||||
|
||||
const _InfoTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
const _QualityOptionItem({
|
||||
required this.quality,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Row(
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$label: ',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.high_quality,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
quality.label,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (quality.description != null && quality.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
quality.description!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
quality.id,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (quality.settings.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'${quality.settings.length} setting${quality.settings.length > 1 ? 's' : ''}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
indent: 72,
|
||||
endIndent: 16,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||
@@ -45,9 +46,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
@@ -72,7 +75,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Extensions',
|
||||
context.l10n.extensionsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -121,8 +124,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
|
||||
// Provider Priority
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Provider Priority'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -135,8 +138,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
|
||||
// Installed Extensions
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Installed Extensions'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
|
||||
),
|
||||
|
||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||
@@ -158,14 +161,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No extensions installed',
|
||||
context.l10n.extensionsNoExtensions,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Install .spotiflac-ext files to add new providers',
|
||||
context.l10n.extensionsNoExtensionsSubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -207,7 +210,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
child: FilledButton.icon(
|
||||
onPressed: _installExtension,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Install Extension'),
|
||||
label: Text(context.l10n.extensionsInstallButton),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -234,8 +237,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Extensions can add new metadata and download providers. '
|
||||
'Only install extensions from trusted sources.',
|
||||
context.l10n.extensionsInfoTip,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
@@ -248,6 +250,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -263,8 +266,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
if (!file.path!.endsWith('.spotiflac-ext')) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select a .spotiflac-ext file'),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarSelectExtFile),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -279,7 +282,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
||||
final extState = ref.read(extensionProvider);
|
||||
String message;
|
||||
if (success) {
|
||||
message = 'Extension installed successfully';
|
||||
message = context.l10n.extensionsInstalledSuccess;
|
||||
} else {
|
||||
// Parse friendly error message
|
||||
message = _getFriendlyErrorMessage(extState.error);
|
||||
@@ -401,8 +404,8 @@ class _ExtensionItem extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasError
|
||||
? extension.errorMessage ?? 'Error loading extension'
|
||||
: 'v${extension.version} by ${extension.author}',
|
||||
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
|
||||
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: hasError
|
||||
? colorScheme.error
|
||||
@@ -471,7 +474,7 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Download Priority',
|
||||
context.l10n.extensionsDownloadPriority,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasDownloadExtensions
|
||||
? null
|
||||
@@ -481,8 +484,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasDownloadExtensions
|
||||
? 'Set download service order'
|
||||
: 'No extensions with download provider',
|
||||
? context.l10n.extensionsDownloadPrioritySubtitle
|
||||
: context.l10n.extensionsNoDownloadProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -540,7 +543,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Metadata Priority',
|
||||
context.l10n.extensionsMetadataPriority,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: hasMetadataExtensions
|
||||
? null
|
||||
@@ -550,8 +553,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
hasMetadataExtensions
|
||||
? 'Set search & metadata source order'
|
||||
: 'No extensions with metadata provider',
|
||||
? context.l10n.extensionsMetadataPrioritySubtitle
|
||||
: context.l10n.extensionsNoMetadataProvider,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -587,7 +590,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
.toList();
|
||||
|
||||
// Get current provider name
|
||||
String currentProviderName = 'Default (Deezer/Spotify)';
|
||||
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||
@@ -616,7 +619,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Search Provider',
|
||||
context.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: searchProviders.isEmpty
|
||||
? colorScheme.outline
|
||||
@@ -626,7 +629,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
searchProviders.isEmpty
|
||||
? 'No extensions with custom search'
|
||||
? context.l10n.extensionsNoCustomSearch
|
||||
: currentProviderName,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -671,7 +674,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text(
|
||||
'Search Provider',
|
||||
ctx.l10n.extensionsSearchProvider,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -680,7 +683,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'Choose which service to use for searching tracks',
|
||||
ctx.l10n.extensionsSearchProviderDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -689,8 +692,8 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
// Default option
|
||||
ListTile(
|
||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||
title: const Text('Default (Deezer/Spotify)'),
|
||||
subtitle: const Text('Use built-in search'),
|
||||
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
@@ -703,7 +706,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
||||
...searchProviders.map((ext) => ListTile(
|
||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||
title: Text(ext.displayName),
|
||||
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
|
||||
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
|
||||
trailing: settings.searchProvider == ext.id
|
||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||
|
||||
@@ -1,6 +1,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/logger.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
@@ -67,7 +68,7 @@ class _LogScreenState extends State<LogScreen> {
|
||||
Clipboard.setData(ClipboardData(text: logs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Logs copied to clipboard'),
|
||||
content: Text(context.l10n.logCopied),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
duration: const Duration(seconds: 2),
|
||||
@@ -84,19 +85,19 @@ class _LogScreenState extends State<LogScreen> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Logs'),
|
||||
content: const Text('Are you sure you want to clear all logs?'),
|
||||
title: Text(context.l10n.logClearLogsTitle),
|
||||
content: Text(context.l10n.logClearLogsMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
LogBuffer().clear();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
child: Text(context.l10n.dialogClear),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -125,60 +126,60 @@ class _LogScreenState extends State<LogScreen> {
|
||||
final logs = _filteredLogs;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button - same as other settings pages
|
||||
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),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy logs',
|
||||
onPressed: _copyLogs,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'share':
|
||||
_shareLogs();
|
||||
break;
|
||||
case 'clear':
|
||||
_clearLogs();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'share',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.share),
|
||||
title: Text('Share logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy logs',
|
||||
onPressed: _copyLogs,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'share':
|
||||
_shareLogs();
|
||||
break;
|
||||
case 'clear':
|
||||
_clearLogs();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'share',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.share),
|
||||
title: Text(context.l10n.logShareLogs),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'clear',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Clear logs'),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'clear',
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: Text(context.l10n.logClearLogs),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
@@ -195,7 +196,7 @@ class _LogScreenState extends State<LogScreen> {
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Logs',
|
||||
context.l10n.logTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -208,8 +209,8 @@ class _LogScreenState extends State<LogScreen> {
|
||||
),
|
||||
|
||||
// Filter section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Filter'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -225,10 +226,10 @@ class _LogScreenState extends State<LogScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Filter logs by severity',
|
||||
context.l10n.logFilterBySeverity,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -279,7 +280,7 @@ class _LogScreenState extends State<LogScreen> {
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search logs...',
|
||||
hintText: context.l10n.logSearchHint,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
@@ -316,7 +317,9 @@ class _LogScreenState extends State<LogScreen> {
|
||||
// Log entries section
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
|
||||
title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty
|
||||
? context.l10n.logEntriesFiltered(logs.length)
|
||||
: context.l10n.logEntries(logs.length),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -342,14 +345,14 @@ class _LogScreenState extends State<LogScreen> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No logs yet',
|
||||
context.l10n.logNoLogsYet,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Logs will appear here as you use the app',
|
||||
context.l10n.logNoLogsYetSubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
@@ -81,7 +82,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save'),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
@@ -96,7 +97,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Metadata Priority',
|
||||
context.l10n.metadataProviderPriorityTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -113,8 +114,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Drag to reorder metadata providers. The app will try providers '
|
||||
'from top to bottom when searching for tracks and fetching metadata.',
|
||||
context.l10n.metadataProviderPriorityDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -166,8 +166,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Deezer has no rate limits and is recommended as primary. '
|
||||
'Spotify may rate limit after many requests.',
|
||||
context.l10n.metadataProviderPriorityInfo,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
@@ -190,16 +189,16 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard Changes?'),
|
||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Discard'),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -214,7 +213,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Metadata provider priority saved')),
|
||||
SnackBar(content: Text(context.l10n.snackbarMetadataProviderSaved)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -246,7 +245,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
final info = _getProviderInfo(provider);
|
||||
final info = _getProviderInfo(context, provider);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
@@ -323,20 +322,20 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
_MetadataProviderInfo _getProviderInfo(String provider) {
|
||||
_MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) {
|
||||
switch (provider) {
|
||||
case 'deezer':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Deezer',
|
||||
icon: Icons.album,
|
||||
description: 'No rate limits',
|
||||
description: context.l10n.metadataNoRateLimits,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
case 'spotify':
|
||||
return _MetadataProviderInfo(
|
||||
name: 'Spotify',
|
||||
icon: Icons.music_note,
|
||||
description: 'May rate limit',
|
||||
description: context.l10n.metadataMayRateLimit,
|
||||
isBuiltIn: true,
|
||||
);
|
||||
default:
|
||||
@@ -344,7 +343,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
||||
return _MetadataProviderInfo(
|
||||
name: provider,
|
||||
icon: Icons.extension,
|
||||
description: 'Extension',
|
||||
description: context.l10n.providerExtension,
|
||||
isBuiltIn: false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
@@ -18,53 +19,53 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
canPop: true, // Always allow back gesture
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button
|
||||
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(
|
||||
'Options',
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Search Source section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Search Source'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -93,7 +94,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
|
||||
context.l10n.optionsSpotifyWarning,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
@@ -107,10 +108,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Spotify Credentials',
|
||||
title: context.l10n.optionsSpotifyCredentials,
|
||||
subtitle: settings.spotifyClientId.isNotEmpty
|
||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||
: 'Required - tap to configure',
|
||||
? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId)
|
||||
: context.l10n.optionsSpotifyCredentialsRequired,
|
||||
onTap: () =>
|
||||
_showSpotifyCredentialsDialog(context, ref, settings),
|
||||
trailing: Icon(
|
||||
@@ -130,16 +131,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Download options section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Download'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.sync,
|
||||
title: 'Auto Fallback',
|
||||
subtitle: 'Try other services if download fails',
|
||||
title: context.l10n.optionsAutoFallback,
|
||||
subtitle: context.l10n.optionsAutoFallbackSubtitle,
|
||||
value: settings.autoFallback,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||
@@ -147,10 +148,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
if (hasExtensions)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.extension,
|
||||
title: 'Use Extension Providers',
|
||||
title: context.l10n.optionsUseExtensionProviders,
|
||||
subtitle: settings.useExtensionProviders
|
||||
? 'Extensions will be tried first'
|
||||
: 'Using built-in providers only',
|
||||
? context.l10n.optionsUseExtensionProvidersOn
|
||||
: context.l10n.optionsUseExtensionProvidersOff,
|
||||
value: settings.useExtensionProviders,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -158,16 +159,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.lyrics,
|
||||
title: 'Embed Lyrics',
|
||||
subtitle: 'Embed synced lyrics into FLAC files',
|
||||
title: context.l10n.optionsEmbedLyrics,
|
||||
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
|
||||
value: settings.embedLyrics,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.image,
|
||||
title: 'Max Quality Cover',
|
||||
subtitle: 'Download highest resolution cover art',
|
||||
title: context.l10n.optionsMaxQualityCover,
|
||||
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
|
||||
value: settings.maxQualityCover,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -179,8 +180,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Performance section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Performance'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
@@ -196,16 +197,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// App section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'App'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.store,
|
||||
title: 'Extension Store',
|
||||
subtitle: 'Show Store tab in navigation',
|
||||
title: context.l10n.optionsExtensionStore,
|
||||
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
||||
value: settings.showExtensionStore,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -213,8 +214,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.system_update,
|
||||
title: 'Check for Updates',
|
||||
subtitle: 'Notify when new version is available',
|
||||
title: context.l10n.optionsCheckUpdates,
|
||||
subtitle: context.l10n.optionsCheckUpdatesSubtitle,
|
||||
value: settings.checkForUpdates,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
@@ -230,16 +231,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Data section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Data'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.delete_forever,
|
||||
title: 'Clear Download History',
|
||||
subtitle: 'Remove all downloaded tracks from history',
|
||||
title: context.l10n.optionsClearHistory,
|
||||
subtitle: context.l10n.optionsClearHistorySubtitle,
|
||||
onTap: () =>
|
||||
_showClearHistoryDialog(context, ref, colorScheme),
|
||||
showDivider: false,
|
||||
@@ -249,18 +250,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
// Debug section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Debug'),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.bug_report,
|
||||
title: 'Detailed Logging',
|
||||
title: context.l10n.optionsDetailedLogging,
|
||||
subtitle: settings.enableLogging
|
||||
? 'Detailed logs are being recorded'
|
||||
: 'Enable for bug reports',
|
||||
? context.l10n.optionsDetailedLoggingOn
|
||||
: context.l10n.optionsDetailedLoggingOff,
|
||||
value: settings.enableLogging,
|
||||
onChanged: (v) =>
|
||||
ref.read(settingsProvider.notifier).setEnableLogging(v),
|
||||
@@ -285,14 +286,14 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear History'),
|
||||
content: const Text(
|
||||
'Are you sure you want to clear all download history? This cannot be undone.',
|
||||
title: Text(context.l10n.dialogClearHistoryTitle),
|
||||
content: Text(
|
||||
context.l10n.dialogClearHistoryMessage,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
@@ -300,9 +301,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('History cleared')));
|
||||
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarHistoryCleared)));
|
||||
},
|
||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
||||
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -353,7 +354,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Spotify Credentials',
|
||||
context.l10n.credentialsTitle,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
@@ -361,7 +362,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Enter your Client ID and Secret to use your own Spotify application quota.',
|
||||
context.l10n.credentialsDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -373,8 +374,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
TextField(
|
||||
controller: clientIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Client ID',
|
||||
hintText: 'Paste Client ID',
|
||||
labelText: context.l10n.credentialsClientId,
|
||||
hintText: context.l10n.credentialsClientIdHint,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
@@ -412,8 +413,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
controller: clientSecretController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Client Secret',
|
||||
hintText: 'Paste Client Secret',
|
||||
labelText: context.l10n.credentialsClientSecret,
|
||||
hintText: context.l10n.credentialsClientSecretHint,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||
alpha: 0.3,
|
||||
@@ -458,12 +459,12 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.setSpotifyCredentials(clientId, clientSecret);
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Credentials saved')),
|
||||
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please fill all fields'),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarFillAllFields),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -474,9 +475,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Save Credentials',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
child: Text(
|
||||
context.l10n.actionSaveCredentials,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -489,14 +490,14 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
.clearSpotifyCredentials();
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Credentials cleared')),
|
||||
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)),
|
||||
);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.error,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: const Text('Remove Credentials'),
|
||||
child: Text(context.l10n.actionRemoveCredentials),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -540,14 +541,14 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Concurrent Downloads',
|
||||
context.l10n.optionsConcurrentDownloads,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentValue == 1
|
||||
? 'Sequential (1 at a time)'
|
||||
: '$currentValue parallel downloads',
|
||||
? context.l10n.optionsConcurrentSequential
|
||||
: context.l10n.optionsConcurrentParallel(currentValue),
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -590,7 +591,7 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Parallel downloads may trigger rate limiting',
|
||||
context.l10n.optionsConcurrentWarning,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
||||
@@ -682,14 +683,14 @@ class _UpdateChannelSelector extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Update Channel',
|
||||
context.l10n.optionsUpdateChannel,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentChannel == 'preview'
|
||||
? 'Get preview releases'
|
||||
: 'Stable releases only',
|
||||
? context.l10n.optionsUpdateChannelPreview
|
||||
: context.l10n.optionsUpdateChannelStable,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -703,13 +704,13 @@ class _UpdateChannelSelector extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
_ChannelChip(
|
||||
label: 'Stable',
|
||||
label: context.l10n.channelStable,
|
||||
isSelected: currentChannel == 'stable',
|
||||
onTap: () => onChanged('stable'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ChannelChip(
|
||||
label: 'Preview',
|
||||
label: context.l10n.channelPreview,
|
||||
isSelected: currentChannel == 'preview',
|
||||
onTap: () => onChanged('preview'),
|
||||
),
|
||||
@@ -726,7 +727,7 @@ class _UpdateChannelSelector extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Preview may contain bugs or incomplete features',
|
||||
context.l10n.optionsUpdateChannelWarning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -823,7 +824,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Primary Provider',
|
||||
context.l10n.optionsPrimaryProvider,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
@@ -831,8 +832,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
hasExtensionSearch
|
||||
? 'Using extension: $extensionName'
|
||||
: 'Service used when searching by track name.',
|
||||
? context.l10n.optionsUsingExtension(extensionName!)
|
||||
: context.l10n.optionsPrimaryProviderSubtitle,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: hasExtensionSearch
|
||||
? colorScheme.primary
|
||||
@@ -845,8 +846,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.graphic_eq,
|
||||
label: 'Deezer',
|
||||
badge: 'Free',
|
||||
badgeColor: colorScheme.tertiary,
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
@@ -861,8 +860,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
_SourceChip(
|
||||
icon: Icons.music_note,
|
||||
label: 'Spotify',
|
||||
badge: 'API Key',
|
||||
badgeColor: colorScheme.secondary,
|
||||
// Not selected if extension is active
|
||||
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
||||
onTap: () {
|
||||
@@ -887,7 +884,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Tap Deezer or Spotify to switch back from extension',
|
||||
context.l10n.optionsSwitchBack,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -907,16 +904,12 @@ class _SourceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback? onTap;
|
||||
final String? badge;
|
||||
final Color? badgeColor;
|
||||
|
||||
const _SourceChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
this.onTap,
|
||||
this.badge,
|
||||
this.badgeColor,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -962,24 +955,6 @@ class _SourceChip extends StatelessWidget {
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (badge != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: (badgeColor ?? colorScheme.tertiary).withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: badgeColor ?? colorScheme.tertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
|
||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
@@ -82,7 +83,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
if (_hasChanges)
|
||||
TextButton(
|
||||
onPressed: _saveChanges,
|
||||
child: const Text('Save'),
|
||||
child: Text(context.l10n.dialogSave),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
@@ -97,7 +98,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Provider Priority',
|
||||
context.l10n.providerPriorityTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -114,8 +115,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Drag to reorder download providers. The app will try providers '
|
||||
'from top to bottom when downloading tracks.',
|
||||
context.l10n.providerPriorityDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
@@ -167,8 +167,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'If a track is not available on the first provider, '
|
||||
'the app will automatically try the next one.',
|
||||
context.l10n.providerPriorityInfo,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
@@ -191,16 +190,16 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Discard Changes?'),
|
||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
||||
title: Text(context.l10n.dialogDiscardChanges),
|
||||
content: Text(context.l10n.dialogUnsavedChanges),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Discard'),
|
||||
child: Text(context.l10n.dialogDiscard),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -215,7 +214,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Provider priority saved')),
|
||||
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -304,7 +303,7 @@ class _ProviderItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
info.isBuiltIn ? 'Built-in' : 'Extension',
|
||||
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||
@@ -41,7 +42,7 @@ class SettingsTab extends ConsumerWidget {
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Settings',
|
||||
context.l10n.settingsTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -55,57 +56,67 @@ class SettingsTab extends ConsumerWidget {
|
||||
|
||||
// First group: Appearance & Download
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.palette_outlined,
|
||||
title: 'Appearance',
|
||||
subtitle: 'Theme, colors, display',
|
||||
onTap: () =>
|
||||
_navigateTo(context, const AppearanceSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.download_outlined,
|
||||
title: 'Download',
|
||||
subtitle: 'Service, quality, filename format',
|
||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: 'Options',
|
||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.extension_outlined,
|
||||
title: 'Extensions',
|
||||
subtitle: 'Manage download providers',
|
||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
return SettingsGroup(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.palette_outlined,
|
||||
title: l10n.settingsAppearance,
|
||||
subtitle: l10n.settingsAppearanceSubtitle,
|
||||
onTap: () =>
|
||||
_navigateTo(context, const AppearanceSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.download_outlined,
|
||||
title: l10n.settingsDownload,
|
||||
subtitle: l10n.settingsDownloadSubtitle,
|
||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.tune_outlined,
|
||||
title: l10n.settingsOptions,
|
||||
subtitle: l10n.settingsOptionsSubtitle,
|
||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.extension_outlined,
|
||||
title: l10n.settingsExtensions,
|
||||
subtitle: l10n.settingsExtensionsSubtitle,
|
||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Second group: Logs & About
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.article_outlined,
|
||||
title: 'Logs',
|
||||
subtitle: 'View app logs for debugging',
|
||||
onTap: () => _navigateTo(context, const LogScreen()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
||||
onTap: () => _navigateTo(context, const AboutPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final l10n = context.l10n;
|
||||
return SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.article_outlined,
|
||||
title: l10n.logTitle,
|
||||
subtitle: l10n.settingsLogsSubtitle,
|
||||
onTap: () => _navigateTo(context, const LogScreen()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.info_outline,
|
||||
title: l10n.settingsAbout,
|
||||
subtitle: '${l10n.aboutVersion} ${AppInfo.version}',
|
||||
onTap: () => _navigateTo(context, const AboutPage()),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -116,6 +127,30 @@ class SettingsTab extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _navigateTo(BuildContext context, Widget page) {
|
||||
Navigator.of(context).push(MaterialPageRoute(builder: (_) => page));
|
||||
// Unfocus any focused widget before navigating to prevent keyboard from appearing on return
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
Navigator.of(context).push(
|
||||
// Use PageRouteBuilder for better predictive back gesture support
|
||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
// Use slide transition similar to MaterialPageRoute
|
||||
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),
|
||||
);
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
class SetupScreen extends ConsumerStatefulWidget {
|
||||
const SetupScreen({super.key});
|
||||
@@ -123,19 +124,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Storage Access Required'),
|
||||
content: const Text(
|
||||
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
|
||||
'Please enable "Allow access to manage all files" in the next screen.',
|
||||
title: Text(context.l10n.setupStorageAccessRequired),
|
||||
content: Text(
|
||||
'${context.l10n.setupStorageAccessMessage}\n\n'
|
||||
'${context.l10n.setupAllowAccessToManageFiles}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Open Settings'),
|
||||
child: Text(context.l10n.setupOpenSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -166,19 +167,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Storage Access Required'),
|
||||
content: const Text(
|
||||
'Android 11+ requires "All files access" permission to save music files.\n\n'
|
||||
'Please enable "Allow access to manage all files" in the next screen.',
|
||||
title: Text(context.l10n.setupStorageAccessRequired),
|
||||
content: Text(
|
||||
'${context.l10n.setupStorageAccessMessageAndroid11}\n\n'
|
||||
'${context.l10n.setupAllowAccessToManageFiles}',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Open Settings'),
|
||||
child: Text(context.l10n.setupOpenSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -211,7 +212,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')),
|
||||
SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -256,22 +257,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('$permissionType Permission Required'),
|
||||
title: Text(context.l10n.setupPermissionRequired(permissionType)),
|
||||
content: Text(
|
||||
'$permissionType permission is required for the best experience. '
|
||||
'Please grant permission in app settings.',
|
||||
context.l10n.setupPermissionRequiredMessage(permissionType),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
openAppSettings();
|
||||
},
|
||||
child: const Text('Open Settings'),
|
||||
child: Text(context.l10n.setupOpenSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -288,7 +288,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
} else {
|
||||
// Android: Use file picker
|
||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||
dialogTitle: 'Select Download Folder',
|
||||
dialogTitle: context.l10n.setupSelectDownloadFolder,
|
||||
);
|
||||
|
||||
if (selectedDirectory != null) {
|
||||
@@ -299,11 +299,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
final useDefault = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Use Default Folder?'),
|
||||
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
||||
title: Text(context.l10n.setupUseDefaultFolder),
|
||||
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel)),
|
||||
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupUseDefault)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -333,19 +333,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
child: Text(context.l10n.setupDownloadLocationTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||
child: Text(
|
||||
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||
context.l10n.setupDownloadLocationIosMessage,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||
title: const Text('App Documents Folder'),
|
||||
subtitle: const Text('Recommended - accessible via Files app'),
|
||||
title: Text(context.l10n.setupAppDocumentsFolder),
|
||||
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
|
||||
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||
onTap: () async {
|
||||
final dir = await _getDefaultDirectory();
|
||||
@@ -355,8 +355,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||
title: const Text('Choose from Files'),
|
||||
subtitle: const Text('Select iCloud or other location'),
|
||||
title: Text(context.l10n.setupChooseFromFiles),
|
||||
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
// Note: iOS requires folder to have at least one file to be selectable
|
||||
@@ -380,7 +380,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||
context.l10n.setupIosEmptyFolderWarning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||
),
|
||||
),
|
||||
@@ -486,16 +486,16 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
Column(
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
ClipRRect(
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('SpotiFLAC',
|
||||
Text(context.l10n.appName,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||
const SizedBox(height: 4),
|
||||
Text('Download Spotify tracks in FLAC',
|
||||
Text(context.l10n.setupDownloadInFlac,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
@@ -529,8 +529,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
|
||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||
final steps = _androidSdkVersion >= 33
|
||||
? ['Storage', 'Notification', 'Folder', 'Spotify']
|
||||
: ['Permission', 'Folder', 'Spotify'];
|
||||
? [context.l10n.setupStepStorage, context.l10n.setupStepNotification, context.l10n.setupStepFolder, context.l10n.setupStepSpotify]
|
||||
: [context.l10n.setupStepPermission, context.l10n.setupStepFolder, context.l10n.setupStepSpotify];
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -653,7 +653,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
_storagePermissionGranted ? 'Storage Permission Granted!' : 'Storage Permission Required',
|
||||
_storagePermissionGranted ? context.l10n.setupStorageGranted : context.l10n.setupStorageRequired,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -662,8 +662,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_storagePermissionGranted
|
||||
? 'You can now proceed to the next step.'
|
||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
||||
? context.l10n.setupProceedToNextStep
|
||||
: context.l10n.setupStorageDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -676,7 +676,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: const Icon(Icons.security_rounded),
|
||||
label: const Text('Grant Permission'),
|
||||
label: Text(context.l10n.setupGrantPermission),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
@@ -707,7 +707,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
||||
_notificationPermissionGranted ? context.l10n.setupNotificationGranted : context.l10n.setupNotificationEnable,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -716,8 +716,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
_notificationPermissionGranted
|
||||
? 'You will receive download progress notifications.'
|
||||
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
||||
? context.l10n.setupNotificationProgressDescription
|
||||
: context.l10n.setupNotificationBackgroundDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -730,7 +730,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: const Icon(Icons.notifications_active_rounded),
|
||||
label: const Text('Enable Notifications'),
|
||||
label: Text(context.l10n.setupEnableNotifications),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
@@ -742,7 +742,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
child: const Text('Skip for now'),
|
||||
child: Text(context.l10n.setupSkipForNow),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -770,7 +770,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
_selectedDirectory != null ? 'Download Folder Selected!' : 'Choose Download Folder',
|
||||
_selectedDirectory != null ? context.l10n.setupFolderSelected : context.l10n.setupFolderChoose,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -802,7 +802,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Select a folder where your downloaded music will be saved.',
|
||||
context.l10n.setupFolderDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -814,7 +814,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
? SizedBox(width: 20, height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||
: Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded),
|
||||
label: Text(_selectedDirectory != null ? 'Change Folder' : 'Select Folder'),
|
||||
label: Text(_selectedDirectory != null ? context.l10n.setupChangeFolder : context.l10n.setupSelectFolder),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
@@ -845,7 +845,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Spotify API (Optional)',
|
||||
context.l10n.setupSpotifyApiOptional,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -853,7 +853,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Add your Spotify API credentials for better search results, or skip to use Deezer instead.',
|
||||
context.l10n.setupSpotifyApiDescription,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -868,9 +868,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
title: Text('Use Spotify API', style: Theme.of(context).textTheme.titleSmall),
|
||||
title: Text(context.l10n.setupUseSpotifyApi, style: Theme.of(context).textTheme.titleSmall),
|
||||
subtitle: Text(
|
||||
_useSpotifyApi ? 'Enter your credentials below' : 'Using Deezer (no account needed)',
|
||||
_useSpotifyApi ? context.l10n.setupEnterCredentialsBelow : context.l10n.setupUsingDeezer,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
secondary: Container(
|
||||
@@ -907,12 +907,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Client ID
|
||||
Text('Client ID', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _clientIdController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter Spotify Client ID',
|
||||
hintText: context.l10n.setupEnterClientId,
|
||||
prefixIcon: const Icon(Icons.key_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -926,13 +926,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Client Secret
|
||||
Text('Client Secret', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text(context.l10n.credentialsClientSecret, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _clientSecretController,
|
||||
obscureText: !_showClientSecret,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter Spotify Client Secret',
|
||||
hintText: context.l10n.setupEnterClientSecret,
|
||||
prefixIcon: const Icon(Icons.lock_rounded),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||
@@ -962,7 +962,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Get credentials from developer.spotify.com',
|
||||
context.l10n.setupGetCredentialsFromSpotify,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||
),
|
||||
),
|
||||
@@ -995,7 +995,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
TextButton.icon(
|
||||
onPressed: () => setState(() => _currentStep--),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
label: const Text('Back'),
|
||||
label: Text(context.l10n.setupBack),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
@@ -1011,9 +1011,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [Text('Next'), SizedBox(width: 8), Icon(Icons.arrow_forward_rounded, size: 18)],
|
||||
children: [Text(context.l10n.setupNext), const SizedBox(width: 8), const Icon(Icons.arrow_forward_rounded, size: 18)],
|
||||
),
|
||||
)
|
||||
else
|
||||
@@ -1029,7 +1029,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
|
||||
Text(_useSpotifyApi ? context.l10n.setupGetStarted : context.l10n.setupSkipAndStart),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.check_rounded, size: 18),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
|
||||
class ExtensionDetailsScreen extends ConsumerStatefulWidget {
|
||||
final StoreExtension extension;
|
||||
|
||||
const ExtensionDetailsScreen({super.key, required this.extension});
|
||||
|
||||
@override
|
||||
ConsumerState<ExtensionDetailsScreen> createState() =>
|
||||
_ExtensionDetailsScreenState();
|
||||
}
|
||||
|
||||
class _ExtensionDetailsScreenState
|
||||
extends ConsumerState<ExtensionDetailsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch store provider to get latest state of this extension (e.g. if updated/installed)
|
||||
final storeState = ref.watch(storeProvider);
|
||||
|
||||
// Find our extension in the store state to get the latest status
|
||||
// If not found in current store state (rare), fallback to widget.extension
|
||||
final liveExtension =
|
||||
storeState.extensions
|
||||
.where((e) => e.id == widget.extension.id)
|
||||
.firstOrNull ??
|
||||
widget.extension;
|
||||
|
||||
final isDownloading = storeState.downloadingId == liveExtension.id;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
_buildAppBar(context, liveExtension, colorScheme),
|
||||
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
context.l10n.aboutTitle,
|
||||
Icons.info_outline,
|
||||
colorScheme,
|
||||
),
|
||||
_buildDescription(context, liveExtension, colorScheme),
|
||||
|
||||
if (liveExtension.tags.isNotEmpty) ...[
|
||||
_buildSectionHeader(context, 'Tags', Icons.tag, colorScheme),
|
||||
_buildTags(context, liveExtension, colorScheme),
|
||||
],
|
||||
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
'Information',
|
||||
Icons.table_chart_outlined,
|
||||
colorScheme,
|
||||
),
|
||||
_buildMetadataTable(context, liveExtension, colorScheme),
|
||||
|
||||
_buildSectionHeader(
|
||||
context,
|
||||
context.l10n.extensionCapabilities,
|
||||
Icons.extension_outlined,
|
||||
colorScheme,
|
||||
),
|
||||
_buildCapabilities(context, liveExtension, colorScheme),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverAppBar(
|
||||
expandedHeight: 200,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: kToolbarHeight),
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: ext.iconUrl != null && ext.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
ext.iconUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_buildFallbackIcon(ext, colorScheme, 50),
|
||||
)
|
||||
: _buildFallbackIcon(ext, colorScheme, 50),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFallbackIcon(
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
double size,
|
||||
) {
|
||||
return Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_getCategoryIcon(ext.category),
|
||||
size: size,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
bool isDownloading,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
ext.displayName,
|
||||
style: Theme.of(context).textTheme.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
context.l10n.extensionsAuthor(ext.author),
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Badges row
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_Badge(
|
||||
label: 'v${ext.version}',
|
||||
color: colorScheme.secondaryContainer,
|
||||
textColor: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
_Badge(
|
||||
label: _getCategoryName(ext.category),
|
||||
color: colorScheme.tertiaryContainer,
|
||||
textColor: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
if (ext.isInstalled)
|
||||
_Badge(
|
||||
label: context.l10n.storeInstalled,
|
||||
color: colorScheme.primaryContainer,
|
||||
textColor: colorScheme.onPrimaryContainer,
|
||||
icon: Icons.check,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Action Buttons
|
||||
if (isDownloading)
|
||||
Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (ext.hasUpdate)
|
||||
FilledButton.icon(
|
||||
onPressed: () => _updateExtension(ext),
|
||||
icon: const Icon(Icons.update),
|
||||
label: Text('${context.l10n.storeUpdate} v${ext.version}'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (ext.isInstalled)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(context.l10n.storeInstalled),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(0, 52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton.filled(
|
||||
onPressed: () => _uninstallExtension(ext),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
foregroundColor: colorScheme.onErrorContainer,
|
||||
minimumSize: const Size(52, 52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
tooltip: context.l10n.extensionsUninstall,
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
FilledButton.icon(
|
||||
onPressed: () => _installExtension(ext),
|
||||
icon: const Icon(Icons.download),
|
||||
label: Text(context.l10n.storeInstall),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHeader(
|
||||
BuildContext context,
|
||||
String title,
|
||||
IconData icon,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescription(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Text(
|
||||
ext.description,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
height: 1.5,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTags(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: ext.tags
|
||||
.map(
|
||||
(tag) => Chip(
|
||||
label: Text(tag),
|
||||
backgroundColor: colorScheme.surfaceContainer,
|
||||
labelStyle: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
side: BorderSide.none,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataTable(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_MetadataRow(
|
||||
label: context.l10n.extensionUpdated,
|
||||
value: ext.updatedAt.isNotEmpty
|
||||
? _formatDate(context, ext.updatedAt)
|
||||
: '-',
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_MetadataRow(
|
||||
label: context.l10n.extensionId,
|
||||
value: ext.id,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_MetadataRow(
|
||||
label: context.l10n.extensionMinAppVersion,
|
||||
value: ext.minAppVersion ?? 'Any',
|
||||
colorScheme: colorScheme,
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCapabilities(
|
||||
BuildContext context,
|
||||
StoreExtension ext,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
// Determine capabilities based on category
|
||||
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
|
||||
final isDownloadProvider = ext.category == 'download';
|
||||
final isLyricsProvider = ext.category == 'lyrics';
|
||||
final isUtility = ext.category == 'utility';
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_CapabilityRow(
|
||||
icon: Icons.search,
|
||||
label: context.l10n.extensionMetadataProvider,
|
||||
enabled: isMetadataProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.download,
|
||||
label: context.l10n.extensionDownloadProvider,
|
||||
enabled: isDownloadProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.lyrics,
|
||||
label: context.l10n.extensionLyricsProvider,
|
||||
enabled: isLyricsProvider,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
_CapabilityRow(
|
||||
icon: Icons.build,
|
||||
label: 'Utility Functions',
|
||||
enabled: isUtility,
|
||||
colorScheme: colorScheme,
|
||||
isLast: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(BuildContext context, String dateStr) {
|
||||
try {
|
||||
final date = DateTime.parse(dateStr);
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inDays == 0) {
|
||||
return context.l10n.dateToday;
|
||||
} else if (diff.inDays == 1) {
|
||||
return context.l10n.dateYesterday;
|
||||
} else if (diff.inDays < 7) {
|
||||
return context.l10n.dateDaysAgo(diff.inDays);
|
||||
} else if (diff.inDays < 30) {
|
||||
return context.l10n.dateWeeksAgo((diff.inDays / 7).floor());
|
||||
} else if (diff.inDays < 365) {
|
||||
return context.l10n.dateMonthsAgo((diff.inDays / 30).floor());
|
||||
} else {
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
} catch (_) {
|
||||
return dateStr.split('T').first;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(String category) {
|
||||
switch (category) {
|
||||
case 'metadata':
|
||||
return Icons.label_outline;
|
||||
case 'download':
|
||||
return Icons.download_outlined;
|
||||
case 'utility':
|
||||
return Icons.build_outlined;
|
||||
case 'lyrics':
|
||||
return Icons.lyrics_outlined;
|
||||
case 'integration':
|
||||
return Icons.link;
|
||||
default:
|
||||
return Icons.extension;
|
||||
}
|
||||
}
|
||||
|
||||
String _getCategoryName(String category) {
|
||||
switch (category) {
|
||||
case 'metadata':
|
||||
return 'Metadata';
|
||||
case 'download':
|
||||
return 'Download';
|
||||
case 'utility':
|
||||
return 'Utility';
|
||||
case 'lyrics':
|
||||
return 'Lyrics';
|
||||
case 'integration':
|
||||
return 'Integration';
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _installExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.installExtension(ext.id, tempDir.path, extensionsDir);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? context.l10n.snackbarExtensionInstalled(ext.displayName)
|
||||
: context.l10n.snackbarFailedToInstall,
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.updateExtension(ext.id, tempDir.path);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
success
|
||||
? context.l10n.snackbarExtensionUpdated(ext.displayName)
|
||||
: context.l10n.snackbarFailedToUpdate,
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uninstallExtension(StoreExtension ext) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.dialogUninstallExtension),
|
||||
content: Text(context.l10n.dialogUninstallExtensionMessage(ext.displayName)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(
|
||||
context.l10n.dialogUninstall,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirm == true) {
|
||||
await ref.read(extensionProvider.notifier).removeExtension(ext.id);
|
||||
await ref.read(storeProvider.notifier).refresh();
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color textColor;
|
||||
final IconData? icon;
|
||||
|
||||
const _Badge({
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.textColor,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 14, color: textColor),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataRow extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final ColorScheme colorScheme;
|
||||
final bool isLast;
|
||||
|
||||
const _MetadataRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.colorScheme,
|
||||
this.isLast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CapabilityRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final ColorScheme colorScheme;
|
||||
final bool isLast;
|
||||
|
||||
const _CapabilityRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.colorScheme,
|
||||
this.isLast = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: enabled ? colorScheme.primary : colorScheme.outline,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
enabled ? Icons.check_circle : Icons.cancel_outlined,
|
||||
size: 20,
|
||||
color: enabled ? colorScheme.primary : colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
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';
|
||||
|
||||
class StoreTab extends ConsumerStatefulWidget {
|
||||
const StoreTab({super.key});
|
||||
@@ -26,6 +28,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_isInitialized = true;
|
||||
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
|
||||
// Check if widget is still mounted after async operation
|
||||
if (!mounted) return;
|
||||
|
||||
await ref.read(storeProvider.notifier).initialize(cacheDir.path);
|
||||
}
|
||||
|
||||
@@ -43,7 +49,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
onRefresh: () =>
|
||||
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar - consistent with other tabs
|
||||
@@ -59,15 +66,16 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||
title: Text(
|
||||
'Store',
|
||||
context.l10n.storeTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (14 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -86,14 +94,16 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search extensions...',
|
||||
hintText: context.l10n.storeSearch,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
ref.read(storeProvider.notifier).setSearchQuery('');
|
||||
ref
|
||||
.read(storeProvider.notifier)
|
||||
.setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
@@ -103,9 +113,15 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Theme.of(context).brightness == Brightness.dark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||
? Color.alphaBlend(
|
||||
Colors.white.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
)
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
ref.read(storeProvider.notifier).setSearchQuery(value);
|
||||
@@ -119,49 +135,68 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
SliverToBoxAdapter(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_CategoryChip(
|
||||
label: 'All',
|
||||
label: context.l10n.storeFilterAll,
|
||||
icon: Icons.apps,
|
||||
isSelected: state.selectedCategory == null,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(null),
|
||||
onTap: () =>
|
||||
ref.read(storeProvider.notifier).setCategory(null),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Metadata',
|
||||
label: context.l10n.storeFilterMetadata,
|
||||
icon: Icons.label_outline,
|
||||
isSelected: state.selectedCategory == StoreCategory.metadata,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.metadata),
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.metadata,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.metadata),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Download',
|
||||
label: context.l10n.storeFilterDownload,
|
||||
icon: Icons.download_outlined,
|
||||
isSelected: state.selectedCategory == StoreCategory.download,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.download),
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.download,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.download),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Utility',
|
||||
label: context.l10n.storeFilterUtility,
|
||||
icon: Icons.build_outlined,
|
||||
isSelected: state.selectedCategory == StoreCategory.utility,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.utility),
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.utility,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.utility),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Lyrics',
|
||||
label: context.l10n.storeFilterLyrics,
|
||||
icon: Icons.lyrics_outlined,
|
||||
isSelected: state.selectedCategory == StoreCategory.lyrics,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.lyrics),
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.lyrics,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.lyrics),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CategoryChip(
|
||||
label: 'Integration',
|
||||
label: context.l10n.storeFilterIntegration,
|
||||
icon: Icons.link,
|
||||
isSelected: state.selectedCategory == StoreCategory.integration,
|
||||
onTap: () => ref.read(storeProvider.notifier).setCategory(StoreCategory.integration),
|
||||
isSelected:
|
||||
state.selectedCategory == StoreCategory.integration,
|
||||
onTap: () => ref
|
||||
.read(storeProvider.notifier)
|
||||
.setCategory(StoreCategory.integration),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -178,9 +213,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
child: _buildErrorState(state.error!, colorScheme),
|
||||
)
|
||||
else if (state.filteredExtensions.isEmpty)
|
||||
SliverFillRemaining(
|
||||
child: _buildEmptyState(state, colorScheme),
|
||||
)
|
||||
SliverFillRemaining(child: _buildEmptyState(state, colorScheme))
|
||||
else ...[
|
||||
// Extensions count
|
||||
SliverToBoxAdapter(
|
||||
@@ -200,15 +233,19 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: SettingsGroup(
|
||||
children: state.filteredExtensions.asMap().entries.map((entry) {
|
||||
children: state.filteredExtensions.asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
final index = entry.key;
|
||||
final ext = entry.value;
|
||||
return _ExtensionItem(
|
||||
extension: ext,
|
||||
showDivider: index < state.filteredExtensions.length - 1,
|
||||
showDivider:
|
||||
index < state.filteredExtensions.length - 1,
|
||||
isDownloading: state.downloadingId == ext.id,
|
||||
onInstall: () => _installExtension(ext),
|
||||
onUpdate: () => _updateExtension(ext),
|
||||
onTap: () => _showExtensionDetails(ext),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -247,9 +284,10 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
onPressed: () =>
|
||||
ref.read(storeProvider.notifier).refresh(forceRefresh: true),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
label: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -258,7 +296,8 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(StoreState state, ColorScheme colorScheme) {
|
||||
final hasFilters = state.searchQuery.isNotEmpty || state.selectedCategory != null;
|
||||
final hasFilters =
|
||||
state.searchQuery.isNotEmpty || state.selectedCategory != null;
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
@@ -283,7 +322,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
_searchController.clear();
|
||||
ref.read(storeProvider.notifier).clearSearch();
|
||||
},
|
||||
child: const Text('Clear filters'),
|
||||
child: Text(context.l10n.storeClearFilters),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -291,23 +330,31 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showExtensionDetails(StoreExtension ext) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ExtensionDetailsScreen(extension: ext),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _installExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final extensionsDir = '${appDir.path}/extensions';
|
||||
|
||||
final success = await ref.read(storeProvider.notifier).installExtension(
|
||||
ext.id,
|
||||
tempDir.path,
|
||||
extensionsDir,
|
||||
);
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.installExtension(ext.id, tempDir.path, extensionsDir);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? '${ext.displayName} installed. Enable it in Settings > Extensions'
|
||||
: 'Failed to install ${ext.displayName}'),
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} installed. Enable it in Settings > Extensions'
|
||||
: 'Failed to install ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
@@ -317,17 +364,18 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
Future<void> _updateExtension(StoreExtension ext) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
final success = await ref.read(storeProvider.notifier).updateExtension(
|
||||
ext.id,
|
||||
tempDir.path,
|
||||
);
|
||||
final success = await ref
|
||||
.read(storeProvider.notifier)
|
||||
.updateExtension(ext.id, tempDir.path);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success
|
||||
? '${ext.displayName} updated to v${ext.version}'
|
||||
: 'Failed to update ${ext.displayName}'),
|
||||
content: Text(
|
||||
success
|
||||
? '${ext.displayName} updated to v${ext.version}'
|
||||
: 'Failed to update ${ext.displayName}',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
@@ -335,7 +383,6 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class _CategoryChip extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
@@ -354,11 +401,7 @@ class _CategoryChip extends StatelessWidget {
|
||||
return FilterChip(
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16),
|
||||
const SizedBox(width: 6),
|
||||
Text(label),
|
||||
],
|
||||
children: [Icon(icon, size: 16), const SizedBox(width: 6), Text(label)],
|
||||
),
|
||||
selected: isSelected,
|
||||
onSelected: (_) => onTap(),
|
||||
@@ -373,6 +416,7 @@ class _ExtensionItem extends StatelessWidget {
|
||||
final bool isDownloading;
|
||||
final VoidCallback onInstall;
|
||||
final VoidCallback onUpdate;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _ExtensionItem({
|
||||
required this.extension,
|
||||
@@ -380,6 +424,7 @@ class _ExtensionItem extends StatelessWidget {
|
||||
required this.isDownloading,
|
||||
required this.onInstall,
|
||||
required this.onUpdate,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
IconData _getCategoryIcon(String category) {
|
||||
@@ -406,151 +451,188 @@ class _ExtensionItem extends StatelessWidget {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Extension icon - custom or category-based
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: extension.isInstalled
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: extension.iconUrl != null && extension.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
extension.iconUrl!,
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Extension icon - custom or category-based
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: extension.isInstalled
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child:
|
||||
extension.iconUrl != null && extension.iconUrl!.isNotEmpty
|
||||
? Image.network(
|
||||
extension.iconUrl!,
|
||||
width: 44,
|
||||
height: 44,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value:
|
||||
loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: loadingProgress.expectedTotalBytes != null
|
||||
? loadingProgress.cumulativeBytesLoaded /
|
||||
loadingProgress.expectedTotalBytes!
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: Icon(
|
||||
_getCategoryIcon(extension.category),
|
||||
color: extension.isInstalled
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Extension info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// Extension info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
extension.displayName,
|
||||
style: Theme.of(context).textTheme.bodyLarge
|
||||
?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
// Version badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'v${extension.version}',
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'by ${extension.author}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
// Version badge
|
||||
),
|
||||
// Warning badge for incompatible extensions
|
||||
if (extension.requiresNewerApp) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'v${extension.version}',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, size: 12, color: colorScheme.onErrorContainer),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Requires v${extension.minAppVersion}+',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Action button
|
||||
if (isDownloading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else if (extension.hasUpdate)
|
||||
FilledButton.tonal(
|
||||
onPressed: onUpdate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Text(context.l10n.storeUpdate),
|
||||
)
|
||||
else if (extension.isInstalled)
|
||||
OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check, size: 16, color: colorScheme.outline),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'Installed',
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'by ${extension.author}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: onInstall,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
extension.description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Action button
|
||||
if (isDownloading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else if (extension.hasUpdate)
|
||||
FilledButton.tonal(
|
||||
onPressed: onUpdate,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
child: Text(context.l10n.storeInstall),
|
||||
),
|
||||
child: const Text('Update'),
|
||||
)
|
||||
else if (extension.isInstalled)
|
||||
OutlinedButton(
|
||||
onPressed: null,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check, size: 16, color: colorScheme.outline),
|
||||
const SizedBox(width: 4),
|
||||
Text('Installed', style: TextStyle(color: colorScheme.outline)),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
FilledButton(
|
||||
onPressed: onInstall,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
),
|
||||
child: const Text('Install'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
|
||||
@@ -4,10 +4,12 @@ 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/utils/mime_utils.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
/// Screen to display detailed metadata for a downloaded track
|
||||
/// Designed with Material Expressive 3 style
|
||||
@@ -27,6 +29,14 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
bool _lyricsLoading = false;
|
||||
String? _lyricsError;
|
||||
|
||||
String? _normalizeOptionalString(String? value) {
|
||||
if (value == null) return null;
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return null;
|
||||
if (trimmed.toLowerCase() == 'null') return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -34,7 +44,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Future<void> _checkFile() async {
|
||||
final file = File(widget.item.filePath);
|
||||
// Strip EXISTS: prefix from legacy history items
|
||||
var filePath = widget.item.filePath;
|
||||
if (filePath.startsWith('EXISTS:')) {
|
||||
filePath = filePath.substring(7);
|
||||
}
|
||||
|
||||
final file = File(filePath);
|
||||
final exists = await file.exists();
|
||||
int? size;
|
||||
|
||||
@@ -62,11 +78,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
String get trackName => item.trackName;
|
||||
String get artistName => item.artistName;
|
||||
String get albumName => item.albumName;
|
||||
String? get albumArtist => item.albumArtist;
|
||||
String? get albumArtist => _normalizeOptionalString(item.albumArtist);
|
||||
int? get trackNumber => item.trackNumber;
|
||||
int? get discNumber => item.discNumber;
|
||||
String? get releaseDate => item.releaseDate;
|
||||
String? get isrc => item.isrc;
|
||||
|
||||
// Clean filePath - strip EXISTS: prefix from legacy history items
|
||||
String get cleanFilePath {
|
||||
final path = item.filePath;
|
||||
return path.startsWith('EXISTS:') ? path.substring(7) : path;
|
||||
}
|
||||
int? get bitDepth => item.bitDepth;
|
||||
int? get sampleRate => item.sampleRate;
|
||||
|
||||
@@ -304,7 +326,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'File not found',
|
||||
context.l10n.trackFileNotFound,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onErrorContainer,
|
||||
fontSize: 12,
|
||||
@@ -340,7 +362,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Metadata',
|
||||
context.l10n.trackMetadata,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
@@ -362,7 +384,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => _openServiceUrl(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
|
||||
label: Text(isDeezer ? context.l10n.trackOpenInDeezer : context.l10n.trackOpenInSpotify),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -419,7 +441,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (context.mounted) {
|
||||
_copyToClipboard(context, webUrl);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
|
||||
SnackBar(content: Text(context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -435,21 +457,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
final items = <_MetadataItem>[
|
||||
_MetadataItem('Track name', trackName),
|
||||
_MetadataItem('Artist', artistName),
|
||||
_MetadataItem(context.l10n.trackTrackName, trackName),
|
||||
_MetadataItem(context.l10n.trackArtist, artistName),
|
||||
if (albumArtist != null && albumArtist != artistName)
|
||||
_MetadataItem('Album artist', albumArtist!),
|
||||
_MetadataItem('Album', albumName),
|
||||
_MetadataItem(context.l10n.trackAlbumArtist, albumArtist!),
|
||||
_MetadataItem(context.l10n.trackAlbum, albumName),
|
||||
if (trackNumber != null && trackNumber! > 0)
|
||||
_MetadataItem('Track number', trackNumber.toString()),
|
||||
_MetadataItem(context.l10n.trackTrackNumber, trackNumber.toString()),
|
||||
if (discNumber != null && discNumber! > 0)
|
||||
_MetadataItem('Disc number', discNumber.toString()),
|
||||
_MetadataItem(context.l10n.trackDiscNumber, discNumber.toString()),
|
||||
if (item.duration != null)
|
||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||
_MetadataItem(context.l10n.trackDuration, _formatDuration(item.duration!)),
|
||||
if (audioQualityStr != null)
|
||||
_MetadataItem('Audio quality', audioQualityStr),
|
||||
_MetadataItem(context.l10n.trackAudioQuality, audioQualityStr),
|
||||
if (releaseDate != null && releaseDate!.isNotEmpty)
|
||||
_MetadataItem('Release date', releaseDate!),
|
||||
_MetadataItem(context.l10n.trackReleaseDate, releaseDate!),
|
||||
if (isrc != null && isrc!.isNotEmpty)
|
||||
_MetadataItem('ISRC', isrc!),
|
||||
];
|
||||
@@ -461,8 +483,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
items.addAll([
|
||||
_MetadataItem('Service', item.service.toUpperCase()),
|
||||
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||
_MetadataItem(context.l10n.trackMetadataService, item.service.toUpperCase()),
|
||||
_MetadataItem(context.l10n.trackDownloaded, _formatFullDate(item.downloadedAt)),
|
||||
]);
|
||||
|
||||
return Column(
|
||||
@@ -515,7 +537,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
}
|
||||
|
||||
Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) {
|
||||
final fileName = item.filePath.split(Platform.pathSeparator).last;
|
||||
final fileName = cleanFilePath.split(Platform.pathSeparator).last;
|
||||
final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown';
|
||||
|
||||
return Card(
|
||||
@@ -536,7 +558,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'File Info',
|
||||
context.l10n.trackFileInfo,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
@@ -631,7 +653,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
// File path
|
||||
InkWell(
|
||||
onTap: () => _copyToClipboard(context, item.filePath),
|
||||
onTap: () => _copyToClipboard(context, cleanFilePath),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -643,7 +665,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.filePath,
|
||||
cleanFilePath,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
@@ -687,7 +709,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Lyrics',
|
||||
context.l10n.trackLyrics,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
@@ -698,7 +720,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy, size: 20),
|
||||
onPressed: () => _copyToClipboard(context, _lyrics!),
|
||||
tooltip: 'Copy lyrics',
|
||||
tooltip: context.l10n.trackCopyLyrics,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -730,7 +752,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _fetchLyrics,
|
||||
child: const Text('Retry'),
|
||||
child: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -753,7 +775,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _fetchLyrics,
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Load Lyrics'),
|
||||
label: Text(context.l10n.trackLoadLyrics),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -776,7 +798,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
item.spotifyId ?? '',
|
||||
item.trackName,
|
||||
item.artistName,
|
||||
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
|
||||
filePath: _fileExists ? cleanFilePath : null, // Try embedded lyrics first
|
||||
).timeout(
|
||||
const Duration(seconds: 20),
|
||||
onTimeout: () => '', // Return empty string on timeout
|
||||
@@ -785,7 +807,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
if (mounted) {
|
||||
if (result.isEmpty) {
|
||||
setState(() {
|
||||
_lyricsError = 'Lyrics not available for this track';
|
||||
_lyricsError = context.l10n.trackLyricsNotAvailable;
|
||||
_lyricsLoading = false;
|
||||
});
|
||||
} else {
|
||||
@@ -800,8 +822,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
final errorMsg = e.toString().contains('TimeoutException')
|
||||
? 'Request timed out. Try again later.'
|
||||
: 'Failed to load lyrics';
|
||||
? context.l10n.trackLyricsTimeout
|
||||
: context.l10n.trackLyricsLoadFailed;
|
||||
setState(() {
|
||||
_lyricsError = errorMsg;
|
||||
_lyricsLoading = false;
|
||||
@@ -833,9 +855,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: FilledButton.icon(
|
||||
onPressed: fileExists ? () => _openFile(context, item.filePath) : null,
|
||||
onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
label: const Text('Play'),
|
||||
label: Text(context.l10n.trackMetadataPlay),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -851,7 +873,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _confirmDelete(context, ref, colorScheme),
|
||||
icon: Icon(Icons.delete_outline, color: colorScheme.error),
|
||||
label: Text('Delete', style: TextStyle(color: colorScheme.error)),
|
||||
label: Text(context.l10n.trackMetadataDelete, style: TextStyle(color: colorScheme.error)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -887,15 +909,15 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: const Text('Copy file path'),
|
||||
title: Text(context.l10n.trackCopyFilePath),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_copyToClipboard(context, item.filePath);
|
||||
_copyToClipboard(context, cleanFilePath);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share),
|
||||
title: const Text('Share'),
|
||||
title: Text(context.l10n.trackMetadataShare),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_shareFile(context);
|
||||
@@ -903,7 +925,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.delete, color: colorScheme.error),
|
||||
title: Text('Remove from device', style: TextStyle(color: colorScheme.error)),
|
||||
title: Text(context.l10n.trackRemoveFromDevice, style: TextStyle(color: colorScheme.error)),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_confirmDelete(context, ref, colorScheme);
|
||||
@@ -920,20 +942,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Remove from device?'),
|
||||
content: const Text(
|
||||
'This will permanently delete the downloaded file and remove it from your history.',
|
||||
),
|
||||
title: Text(context.l10n.trackDeleteConfirmTitle),
|
||||
content: Text(context.l10n.trackDeleteConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
// Delete the file first
|
||||
try {
|
||||
final file = File(item.filePath);
|
||||
final file = File(cleanFilePath);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
@@ -949,7 +969,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
Navigator.pop(context); // Go back to history
|
||||
}
|
||||
},
|
||||
child: Text('Delete', style: TextStyle(color: colorScheme.error)),
|
||||
child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -958,16 +978,17 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
Future<void> _openFile(BuildContext context, String filePath) async {
|
||||
try {
|
||||
final result = await OpenFilex.open(filePath);
|
||||
final mimeType = audioMimeTypeForPath(filePath);
|
||||
final result = await OpenFilex.open(filePath, type: mimeType);
|
||||
if (result.type != ResultType.done && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open: ${result.message}')),
|
||||
SnackBar(content: Text(context.l10n.trackCannotOpen(result.message))),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot open file: $e')),
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -976,19 +997,19 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
void _copyToClipboard(BuildContext context, String text) {
|
||||
Clipboard.setData(ClipboardData(text: text));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Copied to clipboard'),
|
||||
duration: Duration(seconds: 2),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.trackCopiedToClipboard),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _shareFile(BuildContext context) async {
|
||||
final file = File(item.filePath);
|
||||
final file = File(cleanFilePath);
|
||||
if (!await file.exists()) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('File not found')),
|
||||
SnackBar(content: Text(context.l10n.snackbarFileNotFound)),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -996,7 +1017,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: [XFile(item.filePath)],
|
||||
files: [XFile(cleanFilePath)],
|
||||
text: '${item.trackName} - ${item.artistName}',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -199,6 +199,11 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('clearItemProgress', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Cancel an in-progress download
|
||||
static Future<void> cancelDownload(String itemId) async {
|
||||
await _channel.invokeMethod('cancelDownload', {'item_id': itemId});
|
||||
}
|
||||
|
||||
/// Set download directory
|
||||
static Future<void> setDownloadDirectory(String path) async {
|
||||
await _channel.invokeMethod('setDownloadDirectory', {'path': path});
|
||||
@@ -787,6 +792,60 @@ class PlatformBridge {
|
||||
return list.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get album tracks using an extension
|
||||
static Future<Map<String, dynamic>?> getAlbumWithExtension(
|
||||
String extensionId,
|
||||
String albumId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getAlbumWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'album_id': albumId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getAlbumWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist tracks using an extension
|
||||
static Future<Map<String, dynamic>?> getPlaylistWithExtension(
|
||||
String extensionId,
|
||||
String playlistId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getPlaylistWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'playlist_id': playlistId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getPlaylistWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist info and albums using an extension
|
||||
static Future<Map<String, dynamic>?> getArtistWithExtension(
|
||||
String extensionId,
|
||||
String artistId,
|
||||
) async {
|
||||
try {
|
||||
final result = await _channel.invokeMethod('getArtistWithExtension', {
|
||||
'extension_id': extensionId,
|
||||
'artist_id': artistId,
|
||||
});
|
||||
if (result == null || result == '') return null;
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
} catch (e) {
|
||||
_log.e('getArtistWithExtension failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EXTENSION POST-PROCESSING ====================
|
||||
|
||||
/// Run post-processing hooks on a file
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
String audioMimeTypeForPath(String filePath) {
|
||||
final dotIndex = filePath.lastIndexOf('.');
|
||||
if (dotIndex == -1 || dotIndex == filePath.length - 1) {
|
||||
return 'audio/*';
|
||||
}
|
||||
|
||||
final ext = filePath.substring(dotIndex + 1).toLowerCase();
|
||||
switch (ext) {
|
||||
case 'flac':
|
||||
return 'audio/flac';
|
||||
case 'm4a':
|
||||
return 'audio/mp4';
|
||||
case 'mp3':
|
||||
return 'audio/mpeg';
|
||||
case 'ogg':
|
||||
return 'audio/ogg';
|
||||
case 'wav':
|
||||
return 'audio/wav';
|
||||
case 'aac':
|
||||
return 'audio/aac';
|
||||
default:
|
||||
return 'audio/*';
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
/// Built-in service info with quality options
|
||||
class BuiltInService {
|
||||
@@ -167,7 +168,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
'Download From',
|
||||
context.l10n.downloadFrom,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@@ -202,7 +203,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
|
||||
child: Text(
|
||||
'Select Quality',
|
||||
context.l10n.downloadSelectQuality,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
@@ -212,7 +213,7 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
|
||||
child: Text(
|
||||
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
|
||||
context.l10n.qualityNote,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
|
||||
@@ -72,7 +72,7 @@ class SettingsItem extends StatelessWidget {
|
||||
InkWell(
|
||||
onTap: onTap,
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
child: Row(
|
||||
@@ -159,7 +159,7 @@ class SettingsSwitchItem extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: isDisabled ? null : () => onChanged!(!value),
|
||||
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||
highlightColor: Colors.transparent,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/constants/app_info.dart';
|
||||
import 'package:spotiflac_android/services/update_checker.dart';
|
||||
import 'package:spotiflac_android/services/apk_downloader.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
|
||||
class UpdateDialog extends StatefulWidget {
|
||||
final UpdateInfo updateInfo;
|
||||
@@ -42,7 +43,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
setState(() {
|
||||
_isDownloading = true;
|
||||
_progress = 0;
|
||||
_statusText = 'Starting download...';
|
||||
_statusText = context.l10n.updateStartingDownload;
|
||||
});
|
||||
|
||||
final notificationService = NotificationService();
|
||||
@@ -91,11 +92,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_statusText = 'Download failed';
|
||||
_statusText = context.l10n.updateDownloadFailed;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Failed to download update')),
|
||||
SnackBar(content: Text(context.l10n.updateFailedMessage)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -131,9 +132,9 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Update Available', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text(context.l10n.updateAvailable, style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 2),
|
||||
Text('A new version is ready', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
Text(context.l10n.updateNewVersionReady, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -154,11 +155,11 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_VersionChip(version: AppInfo.version, label: 'Current', colorScheme: colorScheme),
|
||||
_VersionChip(version: AppInfo.version, label: context.l10n.updateCurrent, colorScheme: colorScheme),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.arrow_forward_rounded, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
_VersionChip(version: widget.updateInfo.version, label: 'New', colorScheme: colorScheme, isNew: true),
|
||||
_VersionChip(version: widget.updateInfo.version, label: context.l10n.updateNew, colorScheme: colorScheme, isNew: true),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -184,7 +185,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('Downloading...', style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(context.l10n.updateDownloading, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -209,7 +210,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
),
|
||||
] else ...[
|
||||
// Changelog section
|
||||
Text("What's New", style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
||||
Text(context.l10n.updateWhatsNew, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
@@ -240,7 +241,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
)
|
||||
else
|
||||
@@ -251,7 +252,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
child: FilledButton.icon(
|
||||
onPressed: _downloadAndInstall,
|
||||
icon: const Icon(Icons.download_rounded, size: 20),
|
||||
label: const Text('Download & Install'),
|
||||
label: Text(context.l10n.updateDownloadInstall),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
@@ -271,7 +272,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: Text("Don't remind", style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
child: Text(context.l10n.updateDontRemind, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -285,7 +286,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('Later'),
|
||||
child: Text(context.l10n.updateLater),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -382,6 +382,11 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
flutter_localizations:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -488,6 +493,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.7.2"
|
||||
intl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.0.0-beta.1+54
|
||||
version: 3.1.0+59
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -10,6 +10,11 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# Localization
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
intl: any
|
||||
|
||||
# State Management
|
||||
flutter_riverpod: ^3.1.0
|
||||
riverpod_annotation: ^4.0.0
|
||||
@@ -22,7 +27,7 @@ dependencies:
|
||||
path_provider: ^2.1.5
|
||||
|
||||
# HTTP & Network
|
||||
http: ^1.4.0
|
||||
http: ^1.6.0
|
||||
dio: ^5.8.0
|
||||
|
||||
# UI Components
|
||||
@@ -38,7 +43,7 @@ dependencies:
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
# File Picker
|
||||
file_picker: ^10.3.0
|
||||
file_picker: ^10.3.8
|
||||
|
||||
# JSON Serialization
|
||||
json_annotation: ^4.9.0
|
||||
@@ -77,6 +82,7 @@ flutter_launcher_icons:
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
generate: true
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: spotiflac_android
|
||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||
publish_to: "none"
|
||||
version: 3.0.0-beta.1+54
|
||||
version: 3.1.0+59
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.0
|
||||
@@ -10,6 +10,11 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# Localization
|
||||
flutter_localizations:
|
||||
sdk: flutter
|
||||
intl: any
|
||||
|
||||
# State Management
|
||||
flutter_riverpod: ^3.1.0
|
||||
riverpod_annotation: ^4.0.0
|
||||
@@ -22,7 +27,7 @@ dependencies:
|
||||
path_provider: ^2.1.5
|
||||
|
||||
# HTTP & Network
|
||||
http: ^1.4.0
|
||||
http: ^1.6.0
|
||||
dio: ^5.8.0
|
||||
|
||||
# UI Components
|
||||
@@ -38,7 +43,7 @@ dependencies:
|
||||
permission_handler: ^12.0.1
|
||||
|
||||
# File Picker
|
||||
file_picker: ^10.3.0
|
||||
file_picker: ^10.3.8
|
||||
|
||||
# JSON Serialization
|
||||
json_annotation: ^4.9.0
|
||||
@@ -77,6 +82,7 @@ flutter_launcher_icons:
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
generate: true
|
||||
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||