Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61720f3f2a | |||
| 7749399239 | |||
| d143b82068 | |||
| 606e7c1079 | |||
| a650632c4e | |||
| 3c118f74e4 | |||
| bc3055f6e1 | |||
| 7c86ae0b7e | |||
| 595bfb2711 | |||
| 5f39a3d52f | |||
| e7077781e6 | |||
| 42d15db4ca | |||
| c2599981d6 | |||
| a1647a41ff | |||
| bf2fc7702b | |||
| f814408702 | |||
| 6b1958bfd0 | |||
| bc120ffa76 | |||
| 5ea454a0b0 | |||
| da574f895c | |||
| 1c445e91d9 | |||
| 5d03eb0656 | |||
| becb6845a6 | |||
| be3ee3b216 | |||
| 3747674968 | |||
| ff9d088c5f | |||
| 12db11d559 | |||
| 7e1aca33a5 | |||
| 07a1c68354 | |||
| f4d7c6531f | |||
| e9ca054682 | |||
| 1069bdd0d8 | |||
| ff882a58d7 | |||
| dddc8c3d94 | |||
| 720525b67b | |||
| cc12f63d36 | |||
| 5c67553596 | |||
| 0ccda8db58 | |||
| 6d7b89b881 | |||
| 47777b4343 | |||
| 2eb1d2a65d | |||
| ce057c6473 | |||
| 46cfe8b632 | |||
| 2e5eff6e3d | |||
| dd506efeb6 | |||
| 8d92d22fda | |||
| b99764b1ad | |||
| 621582cf11 | |||
| b96233f90b | |||
| be9444c76b | |||
| 65e21a421d | |||
| 87b33dda7e | |||
| 2f097c8f6c | |||
| 8cbdea1417 | |||
| 48bdd154f6 | |||
| ae0e157c34 | |||
| 53fcdd9a47 | |||
| 3d6be3bf92 | |||
| 2d7fba3f52 | |||
| e02d8ff2cd | |||
| f8cee25958 | |||
| 99c133aae1 | |||
| 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 |
@@ -6,6 +6,8 @@ Thumbs.db
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.iml
|
*.iml
|
||||||
|
.cursorignore
|
||||||
|
.cursorrules
|
||||||
|
|
||||||
# Kiro specs (development only)
|
# Kiro specs (development only)
|
||||||
.kiro/
|
.kiro/
|
||||||
@@ -59,3 +61,14 @@ extension/
|
|||||||
|
|
||||||
# Agent instructions
|
# Agent instructions
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
|
# Temp/misc
|
||||||
|
nul
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
hs_err_*.log
|
||||||
|
flutter_*.log
|
||||||
|
|
||||||
|
# Development tools
|
||||||
|
tool/
|
||||||
|
|||||||
@@ -1,5 +1,297 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [3.1.2] - 2026-01-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **New Languages**: Added Spanish (es) and Portuguese (pt) translations
|
||||||
|
- Spanish: Credits 125 ([@credits125](https://crowdin.com/profile/credits125))
|
||||||
|
- Portuguese: Pedro Marcondes ([@justapedro](https://crowdin.com/profile/justapedro))
|
||||||
|
- Russian: Владислав ([@odinokiy_kot](https://crowdin.com/profile/odinokiy_kot))
|
||||||
|
|
||||||
|
- **Quick Search Provider Switcher** ([#76](https://github.com/zarzet/SpotiFLAC-Mobile/issues/76)): Dropdown menu in search bar for instant provider switching
|
||||||
|
- Tap the search icon to reveal a dropdown menu with all available search providers
|
||||||
|
- Shows default provider (Deezer based on metadata source setting) at the top
|
||||||
|
- Lists all enabled extensions with custom search capability
|
||||||
|
- Displays extension icons when available
|
||||||
|
- Checkmark indicates currently selected provider
|
||||||
|
- Search hint text updates immediately when switching providers
|
||||||
|
- Re-triggers search automatically if there's existing text in the search bar
|
||||||
|
- Eliminates need to navigate to Settings > Extensions > Search Provider
|
||||||
|
|
||||||
|
- **Extension Button Setting Type** ([#74](https://github.com/zarzet/SpotiFLAC-Mobile/issues/74)): New setting type for extension actions
|
||||||
|
- Extensions can define `button` type in manifest settings
|
||||||
|
- Triggers JavaScript function when tapped (e.g., start OAuth flow)
|
||||||
|
- Useful for authentication, manual sync, or any custom action
|
||||||
|
|
||||||
|
- **Genre & Label Metadata** ([#75](https://github.com/zarzet/SpotiFLAC-Mobile/issues/75)): Downloaded tracks now include genre and record label information
|
||||||
|
- Fetches genre and label from Deezer album API for each track
|
||||||
|
- Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files
|
||||||
|
- Works automatically when Deezer track ID is available (via ISRC matching)
|
||||||
|
- Supports all download services (Tidal, Qobuz, Amazon) and extension downloads
|
||||||
|
|
||||||
|
- **MP3 Quality Option** ([#69](https://github.com/zarzet/SpotiFLAC-Mobile/issues/69)): Optional MP3 download format with FLAC-to-MP3 conversion
|
||||||
|
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
|
||||||
|
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
|
||||||
|
- Available in both the quality picker dialog and default quality settings
|
||||||
|
- Works with all services (Tidal, Qobuz, Amazon) and extensions
|
||||||
|
|
||||||
|
- **MP3 Metadata Embedding**: Full metadata support for MP3 files
|
||||||
|
- Cover art embedded using ID3v2 tags
|
||||||
|
- Synced lyrics embedded (fetched from lrclib.net)
|
||||||
|
- All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC
|
||||||
|
- Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3)
|
||||||
|
|
||||||
|
- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds
|
||||||
|
- Extracts dominant color from cover art using `palette_generator`
|
||||||
|
- Creates a gradient from dominant color to theme surface color
|
||||||
|
- Smooth 500ms color transition animation
|
||||||
|
|
||||||
|
- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed)
|
||||||
|
- More prominent album artwork display
|
||||||
|
- Larger shadow and rounded corners (20px radius)
|
||||||
|
- Higher resolution cover caching
|
||||||
|
|
||||||
|
- **Sticky Title**: Title appears in AppBar when scrolling past the info card
|
||||||
|
- Smooth fade-in animation (200ms) when scrolling down
|
||||||
|
- Title hidden when header is expanded (shows in info card instead)
|
||||||
|
- AppBar uses theme color (surface) for clean, native look
|
||||||
|
- Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens
|
||||||
|
|
||||||
|
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
|
||||||
|
- Extracted from first track's artist metadata
|
||||||
|
- Styled with `onSurfaceVariant` color for visual hierarchy
|
||||||
|
|
||||||
|
- **Disc Separation for Multi-Disc Albums** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloaded albums with multiple discs now display tracks grouped by disc
|
||||||
|
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
|
||||||
|
- Tracks sorted by disc number first, then by track number
|
||||||
|
- Single-disc albums display normally without separators
|
||||||
|
- Fixes confusion when albums have duplicate track numbers across discs
|
||||||
|
|
||||||
|
- **Album Grouping in Recents** ([#70](https://github.com/zarzet/SpotiFLAC-Mobile/issues/70)): Downloads now show as albums instead of individual tracks in the Recent section
|
||||||
|
- Prevents flooding the recents list when downloading full albums
|
||||||
|
- Groups tracks by album name and artist
|
||||||
|
- Tapping navigates directly to the downloaded album screen
|
||||||
|
- Shows the most recent download time for each album
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **FFmpeg FLAC-to-MP3 Conversion**: Improved conversion process
|
||||||
|
- MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder)
|
||||||
|
- Original FLAC file automatically deleted after successful conversion
|
||||||
|
- New `embedMetadataToMp3()` method for MP3-specific tag embedding
|
||||||
|
|
||||||
|
- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed
|
||||||
|
- Dark theme: Black background with white text
|
||||||
|
- Light theme: White background with black text
|
||||||
|
- Matches modern app behavior for better readability
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **MP3 Quality Display in Track Metadata**: Fixed incorrect quality display for MP3 files
|
||||||
|
- MP3 files now show "320kbps" instead of FLAC's bit depth/sample rate
|
||||||
|
- History no longer stores FLAC audio specs for converted MP3 files
|
||||||
|
- Both File Info badges and metadata grid show correct MP3 quality
|
||||||
|
|
||||||
|
- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks
|
||||||
|
- `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored
|
||||||
|
- `track_provider.dart`: Added comments explaining why availability check errors are silently ignored
|
||||||
|
- `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures
|
||||||
|
|
||||||
|
- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization
|
||||||
|
- Removed redundant `=1` clauses that were overriding `one` plural category
|
||||||
|
- Affected 10 plural strings including track counts and delete confirmations
|
||||||
|
- Plurals now correctly handle Russian grammar (1 трек, 2 трека, 5 треков)
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Added `palette_generator: ^0.3.3+4` for cover art color extraction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.1.1] - 2026-01-17
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Lyrics Caching**: Lyrics are now cached for 24 hours to reduce API calls and improve performance
|
||||||
|
- Thread-safe cache with automatic expiration
|
||||||
|
- Cache key based on artist, track, and duration
|
||||||
|
- Log indicator shows "(cached)" when lyrics are served from cache
|
||||||
|
|
||||||
|
- **Lyrics Duration Matching**: Improved lyrics accuracy with duration-based matching
|
||||||
|
- Compares track duration with lrclib.net results
|
||||||
|
- 10-second tolerance to handle version differences (radio edit, remaster, etc.)
|
||||||
|
- Prioritizes synced lyrics over plain text when duration matches
|
||||||
|
- Falls back gracefully if no duration match found
|
||||||
|
|
||||||
|
- **Deezer Cover Art Upgrade**: Cover art from Deezer CDN now automatically upgraded to maximum quality
|
||||||
|
- Detects Deezer CDN URLs (`cdn-images.dzcdn.net`)
|
||||||
|
- Upgrades cover resolution to 1800x1800 (max available)
|
||||||
|
- Works alongside existing cover upgrade
|
||||||
|
|
||||||
|
- **Live Search for Extensions**: Search-as-you-type functionality for extension search
|
||||||
|
- 800ms debounce delay to prevent excessive API calls
|
||||||
|
- Minimum 3 characters required before searching
|
||||||
|
- Concurrency control to prevent race conditions in extension runtime
|
||||||
|
- Queues pending searches if a search is already in progress
|
||||||
|
|
||||||
|
- **Russian Language Support**: Added Russian (Русский) translation - 99% complete
|
||||||
|
- Translated via Crowdin community contributions
|
||||||
|
- Covers all UI elements, settings, and error messages
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ISRC Index Race Condition**: Fixed repeated index rebuilding during parallel downloads
|
||||||
|
- Added per-directory build lock using `sync.Map` and `sync.Mutex`
|
||||||
|
- Double-check locking pattern ensures index is built only once
|
||||||
|
- Significantly improves performance during CSV import with many tracks
|
||||||
|
|
||||||
|
- **Queue Tab Scroll Exception**: Fixed Flutter rendering exception with NestedScrollView
|
||||||
|
- Disabled Material 3 stretch overscroll indicator that caused `_StretchController` assertion
|
||||||
|
- Wrapped NestedScrollView with ScrollConfiguration to prevent `setState during build` errors
|
||||||
|
- Issue was especially noticeable during rapid queue updates (CSV import)
|
||||||
|
|
||||||
|
- **CSV Import**: Fixed CSV export not being parsed correctly
|
||||||
|
- Added support for `Artist Name(s)` header (with parentheses)
|
||||||
|
- Added support for `Track URI` header for track IDs
|
||||||
|
- Added `artists` and `track_id` as alternative header names
|
||||||
|
- Now correctly parses "Liked Songs" and playlist exports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [3.1.0] - 2026-01-16
|
||||||
|
|
||||||
|
### 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 metadata 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, etc.) instead of trying to fetch from 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
|
||||||
|
- **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
|
## [3.0.0] - 2026-01-14
|
||||||
|
|
||||||
### Extension System (Major Feature)
|
### Extension System (Major Feature)
|
||||||
@@ -15,12 +307,12 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
- One-tap install, update, and uninstall
|
- One-tap install, update, and uninstall
|
||||||
- Offline cache for browsing without internet
|
- Offline cache for browsing without internet
|
||||||
|
|
||||||
#### Spotify Web Extension
|
#### Web Extension
|
||||||
|
|
||||||
- Available in Extension Store - install and enable in Settings > Extensions
|
- Available in Extension Store - install and enable in Settings > Extensions
|
||||||
- Metadata provider using Spotify's internal web player API
|
- Metadata provider using web player API
|
||||||
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
|
- Download tracks from Daily Mix, Discover Weekly, and other personalized playlists
|
||||||
- Useful when official Spotify API is rate-limited or unavailable
|
- Useful when official API is rate-limited or unavailable
|
||||||
|
|
||||||
#### Extension Capabilities
|
#### Extension Capabilities
|
||||||
|
|
||||||
@@ -55,9 +347,16 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
|
- **Separate Singles Folder**: Organize downloads into Albums/ and Singles/ folders
|
||||||
|
|
||||||
- Based on `album_type` from Spotify/Deezer metadata
|
- Based on `album_type` from metadata
|
||||||
- Toggle in Settings > Download > Separate Singles Folder
|
- 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
|
- **Parallel API Calls**: Download URL fetching now uses parallel requests
|
||||||
- Tidal: All 8 APIs requested simultaneously, first success wins
|
- Tidal: All 8 APIs requested simultaneously, first success wins
|
||||||
- Qobuz: Both APIs requested simultaneously, first success wins
|
- Qobuz: Both APIs requested simultaneously, first success wins
|
||||||
@@ -86,7 +385,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
- **Fixed Keyboard Appearing on Tab Switch**: Keyboard now auto-dismisses when swiping between tabs
|
- **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
|
- **Removed Search Source Badges**: Removed "Free" and "API Key" labels from provider selector in Options
|
||||||
|
|
||||||
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
|
- **Back Gesture Freeze on Android 13+**: Fixed app freeze when using back gesture in settings
|
||||||
|
|
||||||
@@ -121,7 +420,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||||
|
|
||||||
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
- Detects existing entries by track ID, Deezer ID, or ISRC
|
||||||
|
|
||||||
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
|
- **Permission Error Message**: Fixed download showing "Song not found" when actually permission error
|
||||||
|
|
||||||
@@ -190,7 +489,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
- **Extension Disabled Search Fallback**: Fixed error when extension is disabled but still called
|
- **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
|
- `_performSearch` now checks if extension is still enabled before calling custom search
|
||||||
- Automatically falls back to Deezer/Spotify search if extension was disabled
|
- Automatically falls back to Deezer search if extension was disabled
|
||||||
- Clears `searchProvider` setting if extension no longer available
|
- Clears `searchProvider` setting if extension no longer available
|
||||||
|
|
||||||
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
- **Store Tab Unmount Crash**: Fixed "Using ref when widget is unmounted" error
|
||||||
@@ -310,7 +609,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
- **Spotify Web Extension** (example): New extension for Spotify metadata via web API
|
- **Web Extension** (example): New extension for metadata via web API
|
||||||
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
|
- Supports personalized playlists (Daily Mix, Discover Weekly, Release Radar, etc.)
|
||||||
- Search, album, playlist, track, and artist fetching
|
- Search, album, playlist, track, and artist fetching
|
||||||
- Available in Extension Store (3.0.0-alpha.4)
|
- Available in Extension Store (3.0.0-alpha.4)
|
||||||
@@ -322,7 +621,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
|
- **Separate Singles Folder**: Option to organize downloads into Albums/ and Singles/ folders
|
||||||
- Based on `album_type` from Spotify/Deezer metadata
|
- Based on `album_type` from metadata
|
||||||
- Toggle in Settings > Download > Separate Singles Folder
|
- Toggle in Settings > Download > Separate Singles Folder
|
||||||
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
|
- Singles saved to `{output}/Singles/`, albums to `{output}/Albums/`
|
||||||
- **Browser-like Polyfills**: New global APIs for easier library porting
|
- **Browser-like Polyfills**: New global APIs for easier library porting
|
||||||
@@ -342,7 +641,7 @@ SpotiFLAC 3.0 introduces a powerful extension system that allows third-party int
|
|||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
- **Duplicate History Entries**: Fixed duplicate entries when re-downloading same track
|
||||||
- Detects existing entries by Spotify ID, Deezer ID, or ISRC
|
- Detects existing entries by track ID, Deezer ID, or ISRC
|
||||||
- Replaces existing entry and moves to top of list
|
- Replaces existing entry and moves to top of list
|
||||||
- Auto-deduplicates existing history on app load
|
- Auto-deduplicates existing history on app load
|
||||||
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
|
- **Extension Search Fallback**: Fixed error when extension is disabled but still called for search
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](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">
|
<div align="center">
|
||||||
|
|
||||||
<img src="icon.png" width="128" />
|
<img src="icon.png" width="128" />
|
||||||
|
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
@@ -23,22 +24,14 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account
|
|||||||
<img src="assets/images/4.jpg?v=2" width="200" />
|
<img src="assets/images/4.jpg?v=2" width="200" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Metadata Source
|
## Search Source
|
||||||
|
|
||||||
SpotiFLAC supports two metadata sources for searching tracks:
|
SpotiFLAC supports multiple search sources for finding music metadata:
|
||||||
|
|
||||||
| Source | Pros | Cons |
|
| Source | Setup |
|
||||||
|--------|------|------|
|
|--------|-------|
|
||||||
| **Deezer** (Default) | No developer account needed, rate limit per user IP | Slightly less comprehensive catalog |
|
| **Deezer** (Default) | No setup required |
|
||||||
| **Spotify** | More comprehensive catalog, better search results | Requires developer API credentials to avoid rate limiting |
|
| **Extensions** | Install additional search providers from the Store |
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
@@ -57,7 +50,24 @@ Want to create your own extension? Check out the [Extension Development Guide](h
|
|||||||
## Other project
|
## Other project
|
||||||
|
|
||||||
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
### [SpotiFLAC (Desktop)](https://github.com/afkarxyz/SpotiFLAC)
|
||||||
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, macOS & Linux
|
Download music in true lossless 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 playlists?**
|
||||||
|
A: Yes! Just paste the 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: 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)
|
[](https://ko-fi.com/zarzet)
|
||||||
|
|
||||||
@@ -65,7 +75,9 @@ Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music for Windows, ma
|
|||||||
|
|
||||||
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
|
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.
|
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service.
|
||||||
|
|
||||||
|
The application is purely a user interface that facilitates communication between your device and existing third-party services.
|
||||||
|
|
||||||
You are solely responsible for:
|
You are solely responsible for:
|
||||||
1. Ensuring your use of this software complies with your local laws.
|
1. Ensuring your use of this software complies with your local laws.
|
||||||
|
|||||||
@@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"cancelDownload" -> {
|
||||||
|
val itemId = call.argument<String>("item_id") ?: ""
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.cancelDownload(itemId)
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"setDownloadDirectory" -> {
|
"setDownloadDirectory" -> {
|
||||||
val path = call.argument<String>("path") ?: ""
|
val path = call.argument<String>("path") ?: ""
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
@@ -151,8 +158,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
|
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.fetchLyrics(spotifyId, trackName, artistName)
|
Gobackend.fetchLyrics(spotifyId, trackName, artistName, durationMs)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -161,8 +169,9 @@ class MainActivity: FlutterActivity() {
|
|||||||
val trackName = call.argument<String>("track_name") ?: ""
|
val trackName = call.argument<String>("track_name") ?: ""
|
||||||
val artistName = call.argument<String>("artist_name") ?: ""
|
val artistName = call.argument<String>("artist_name") ?: ""
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
val filePath = call.argument<String>("file_path") ?: ""
|
||||||
|
val durationMs = call.argument<Int>("duration_ms")?.toLong() ?: 0L
|
||||||
val response = withContext(Dispatchers.IO) {
|
val response = withContext(Dispatchers.IO) {
|
||||||
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath)
|
Gobackend.getLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs)
|
||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
@@ -275,6 +284,13 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
result.success(response)
|
||||||
}
|
}
|
||||||
|
"getDeezerExtendedMetadata" -> {
|
||||||
|
val trackId = call.argument<String>("track_id") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.getDeezerExtendedMetadata(trackId)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"convertSpotifyToDeezer" -> {
|
"convertSpotifyToDeezer" -> {
|
||||||
val resourceType = call.argument<String>("resource_type") ?: ""
|
val resourceType = call.argument<String>("resource_type") ?: ""
|
||||||
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
val spotifyId = call.argument<String>("spotify_id") ?: ""
|
||||||
@@ -429,6 +445,14 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
"invokeExtensionAction" -> {
|
||||||
|
val extensionId = call.argument<String>("extension_id") ?: ""
|
||||||
|
val actionName = call.argument<String>("action") ?: ""
|
||||||
|
val response = withContext(Dispatchers.IO) {
|
||||||
|
Gobackend.invokeExtensionActionJSON(extensionId, actionName)
|
||||||
|
}
|
||||||
|
result.success(response)
|
||||||
|
}
|
||||||
"searchTracksWithExtensions" -> {
|
"searchTracksWithExtensions" -> {
|
||||||
val query = call.argument<String>("query") ?: ""
|
val query = call.argument<String>("query") ?: ""
|
||||||
val limit = call.argument<Int>("limit") ?: 20
|
val limit = call.argument<Int>("limit") ?: 20
|
||||||
@@ -572,6 +596,30 @@ class MainActivity: FlutterActivity() {
|
|||||||
}
|
}
|
||||||
result.success(response)
|
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
|
// Extension Post-Processing API
|
||||||
"runPostProcessing" -> {
|
"runPostProcessing" -> {
|
||||||
val filePath = call.argument<String>("file_path") ?: ""
|
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 |
@@ -42,17 +42,27 @@ class FFmpegServiceIOS {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert FLAC to MP3
|
/// Convert FLAC to MP3
|
||||||
static Future<String?> convertFlacToMp3(String inputPath, {String bitrate = '320k'}) async {
|
/// If deleteOriginal is true, deletes the FLAC file after conversion
|
||||||
final dir = File(inputPath).parent.path;
|
static Future<String?> convertFlacToMp3(
|
||||||
final baseName = inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
|
String inputPath, {
|
||||||
final outputDir = '$dir${Platform.pathSeparator}MP3';
|
String bitrate = '320k',
|
||||||
await Directory(outputDir).create(recursive: true);
|
bool deleteOriginal = true,
|
||||||
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
|
}) async {
|
||||||
|
// Convert in same folder, just change extension
|
||||||
|
final outputPath = inputPath.replaceAll('.flac', '.mp3');
|
||||||
|
|
||||||
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
final command = '-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
|
||||||
final result = await _execute(command);
|
final result = await _execute(command);
|
||||||
|
|
||||||
if (result.success) return outputPath;
|
if (result.success) {
|
||||||
|
// Delete original FLAC if requested
|
||||||
|
if (deleteOriginal) {
|
||||||
|
try {
|
||||||
|
await File(inputPath).delete();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
_log.e('FLAC to MP3 conversion failed: ${result.output}');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -177,6 +187,123 @@ class FFmpegServiceIOS {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Embed metadata and cover art to MP3 file using ID3v2 tags
|
||||||
|
/// Returns the file path on success, null on failure
|
||||||
|
static Future<String?> embedMetadataToMp3({
|
||||||
|
required String mp3Path,
|
||||||
|
String? coverPath,
|
||||||
|
Map<String, String>? metadata,
|
||||||
|
}) async {
|
||||||
|
final tempOutput = '$mp3Path.tmp';
|
||||||
|
|
||||||
|
final StringBuffer cmdBuffer = StringBuffer();
|
||||||
|
cmdBuffer.write('-i "$mp3Path" ');
|
||||||
|
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-i "$coverPath" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('-map 0:a ');
|
||||||
|
|
||||||
|
if (coverPath != null) {
|
||||||
|
cmdBuffer.write('-map 1:0 ');
|
||||||
|
cmdBuffer.write('-c:v:0 copy ');
|
||||||
|
cmdBuffer.write('-id3v2_version 3 ');
|
||||||
|
cmdBuffer.write('-metadata:s:v title="Album cover" ');
|
||||||
|
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('-c:a copy ');
|
||||||
|
|
||||||
|
if (metadata != null) {
|
||||||
|
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
|
||||||
|
final id3Metadata = _convertToId3Tags(metadata);
|
||||||
|
id3Metadata.forEach((key, value) {
|
||||||
|
final sanitizedValue = value.replaceAll('"', '\\"');
|
||||||
|
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
|
||||||
|
|
||||||
|
final command = cmdBuffer.toString();
|
||||||
|
_log.d('Executing FFmpeg MP3 embed command: $command');
|
||||||
|
|
||||||
|
final result = await _execute(command);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
try {
|
||||||
|
await File(mp3Path).delete();
|
||||||
|
await File(tempOutput).rename(mp3Path);
|
||||||
|
_log.d('MP3 metadata embedded successfully');
|
||||||
|
return mp3Path;
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Failed to replace MP3 file after metadata embed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final tempFile = File(tempOutput);
|
||||||
|
if (await tempFile.exists()) {
|
||||||
|
await tempFile.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
|
||||||
|
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
|
||||||
|
final id3Map = <String, String>{};
|
||||||
|
|
||||||
|
for (final entry in vorbisMetadata.entries) {
|
||||||
|
final key = entry.key.toUpperCase();
|
||||||
|
final value = entry.value;
|
||||||
|
|
||||||
|
// Map Vorbis comments to ID3v2 frame names
|
||||||
|
switch (key) {
|
||||||
|
case 'TITLE':
|
||||||
|
id3Map['title'] = value;
|
||||||
|
break;
|
||||||
|
case 'ARTIST':
|
||||||
|
id3Map['artist'] = value;
|
||||||
|
break;
|
||||||
|
case 'ALBUM':
|
||||||
|
id3Map['album'] = value;
|
||||||
|
break;
|
||||||
|
case 'ALBUMARTIST':
|
||||||
|
id3Map['album_artist'] = value;
|
||||||
|
break;
|
||||||
|
case 'TRACKNUMBER':
|
||||||
|
case 'TRACK':
|
||||||
|
id3Map['track'] = value;
|
||||||
|
break;
|
||||||
|
case 'DISCNUMBER':
|
||||||
|
case 'DISC':
|
||||||
|
id3Map['disc'] = value;
|
||||||
|
break;
|
||||||
|
case 'DATE':
|
||||||
|
case 'YEAR':
|
||||||
|
id3Map['date'] = value;
|
||||||
|
break;
|
||||||
|
case 'ISRC':
|
||||||
|
id3Map['TSRC'] = value; // ID3v2 ISRC frame
|
||||||
|
break;
|
||||||
|
case 'LYRICS':
|
||||||
|
case 'UNSYNCEDLYRICS':
|
||||||
|
id3Map['lyrics'] = value;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Pass through other tags as-is
|
||||||
|
id3Map[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return id3Map;
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if FFmpeg is available
|
/// Check if FFmpeg is available
|
||||||
static Future<bool> isAvailable() async {
|
static Future<bool> isAvailable() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
files:
|
||||||
|
- source: /lib/l10n/arb/app_en.arb
|
||||||
|
translation: /lib/l10n/arb/app_%locale%.arb
|
||||||
|
languages_mapping:
|
||||||
|
locale:
|
||||||
|
# Short codes for single-variant languages
|
||||||
|
de: de
|
||||||
|
es: es
|
||||||
|
fr: fr
|
||||||
|
hi: hi
|
||||||
|
id: id
|
||||||
|
ja: ja
|
||||||
|
ko: ko
|
||||||
|
nl: nl
|
||||||
|
pt: pt
|
||||||
|
ru: ru
|
||||||
|
# Full codes for Chinese variants
|
||||||
|
zh-CN: zh_CN
|
||||||
|
zh-TW: zh_TW
|
||||||
@@ -2,8 +2,10 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,10 +27,9 @@ type AmazonDownloader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Amazon downloader instance for connection reuse
|
|
||||||
globalAmazonDownloader *AmazonDownloader
|
globalAmazonDownloader *AmazonDownloader
|
||||||
amazonDownloaderOnce sync.Once
|
amazonDownloaderOnce sync.Once
|
||||||
amazonRateLimitMu sync.Mutex // Mutex for rate limiting
|
amazonRateLimitMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
|
||||||
@@ -53,17 +54,14 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
normExpected := strings.ToLower(strings.TrimSpace(expectedArtist))
|
||||||
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
normFound := strings.ToLower(strings.TrimSpace(foundArtist))
|
||||||
|
|
||||||
// Exact match
|
|
||||||
if normExpected == normFound {
|
if normExpected == normFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if one contains the other
|
|
||||||
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
if strings.Contains(normExpected, normFound) || strings.Contains(normFound, normExpected) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check first artist (before comma or feat)
|
|
||||||
expectedFirst := strings.Split(normExpected, ",")[0]
|
expectedFirst := strings.Split(normExpected, ",")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
expectedFirst = strings.Split(expectedFirst, " feat")[0]
|
||||||
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
expectedFirst = strings.Split(expectedFirst, " ft.")[0]
|
||||||
@@ -78,13 +76,10 @@ func amazonArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if first artist is contained in the other
|
|
||||||
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
if strings.Contains(expectedFirst, foundFirst) || strings.Contains(foundFirst, expectedFirst) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are different (one is ASCII, one is non-ASCII like Japanese/Chinese/Korean),
|
|
||||||
// assume they're the same artist with different transliteration
|
|
||||||
expectedASCII := amazonIsASCIIString(expectedArtist)
|
expectedASCII := amazonIsASCIIString(expectedArtist)
|
||||||
foundASCII := amazonIsASCIIString(foundArtist)
|
foundASCII := amazonIsASCIIString(foundArtist)
|
||||||
if expectedASCII != foundASCII {
|
if expectedASCII != foundASCII {
|
||||||
@@ -125,7 +120,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Reset counter every minute
|
|
||||||
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
if now.Sub(a.apiCallResetTime) >= time.Minute {
|
||||||
a.apiCallCount = 0
|
a.apiCallCount = 0
|
||||||
a.apiCallResetTime = now
|
a.apiCallResetTime = now
|
||||||
@@ -153,7 +147,6 @@ func (a *AmazonDownloader) waitForRateLimit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update tracking
|
|
||||||
a.lastAPICallTime = time.Now()
|
a.lastAPICallTime = time.Now()
|
||||||
a.apiCallCount++
|
a.apiCallCount++
|
||||||
}
|
}
|
||||||
@@ -179,8 +172,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
|||||||
for _, region := range a.regions {
|
for _, region := range a.regions {
|
||||||
GoLog("[Amazon] Trying region: %s...\n", region)
|
GoLog("[Amazon] Trying region: %s...\n", region)
|
||||||
|
|
||||||
// Build base URL for DoubleDouble service
|
|
||||||
// Decode base64 service URL (same as PC)
|
|
||||||
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https://
|
||||||
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top
|
||||||
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain))
|
||||||
@@ -299,7 +290,6 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
|||||||
if status.Status == "done" {
|
if status.Status == "done" {
|
||||||
fmt.Println("\n[Amazon] Download ready!")
|
fmt.Println("\n[Amazon] Download ready!")
|
||||||
|
|
||||||
// Build download URL
|
|
||||||
fileURL := status.URL
|
fileURL := status.URL
|
||||||
if strings.HasPrefix(fileURL, "./") {
|
if strings.HasPrefix(fileURL, "./") {
|
||||||
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
|
||||||
@@ -346,13 +336,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string)
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
@@ -361,6 +359,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
|
|
||||||
resp, err := a.client.Do(req)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -370,7 +371,6 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -380,16 +380,13 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(bufWriter, itemID)
|
pw := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(pw, resp.Body)
|
written, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,9 +394,11 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -440,24 +439,19 @@ type AmazonDownloadResult struct {
|
|||||||
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
||||||
downloader := NewAmazonDownloader()
|
downloader := NewAmazonDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Amazon URL from SongLink
|
|
||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
var availability *TrackAvailability
|
var availability *TrackAvailability
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
// Extract Deezer ID and use Deezer-based lookup
|
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
availability, err = songlink.CheckAvailabilityFromDeezer(deezerID)
|
||||||
} else if req.SpotifyID != "" {
|
} else if req.SpotifyID != "" {
|
||||||
// Use Spotify ID
|
|
||||||
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC)
|
||||||
} else {
|
} else {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup")
|
||||||
@@ -471,7 +465,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory if needed
|
|
||||||
if req.OutputDir != "." {
|
if req.OutputDir != "." {
|
||||||
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
if err := os.MkdirAll(req.OutputDir, 0755); err != nil {
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err)
|
||||||
@@ -490,10 +483,8 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log match found
|
|
||||||
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName)
|
||||||
|
|
||||||
// Build filename using Spotify metadata (more accurate)
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -505,7 +496,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
@@ -522,19 +512,21 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return AmazonDownloadResult{}, ErrDownloadCancelled
|
||||||
|
}
|
||||||
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
@@ -545,14 +537,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read existing metadata from downloaded file BEFORE embedding
|
|
||||||
// Amazon/DoubleDouble files often have correct track/disc numbers that we should preserve
|
|
||||||
existingMeta, metaErr := ReadMetadata(outputPath)
|
existingMeta, metaErr := ReadMetadata(outputPath)
|
||||||
actualTrackNum := req.TrackNumber
|
actualTrackNum := req.TrackNumber
|
||||||
actualDiscNum := req.DiscNumber
|
actualDiscNum := req.DiscNumber
|
||||||
|
|
||||||
if metaErr == nil && existingMeta != nil {
|
if metaErr == nil && existingMeta != nil {
|
||||||
// Use file metadata if it has valid track/disc numbers and request doesn't have them
|
|
||||||
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) {
|
||||||
actualTrackNum = existingMeta.TrackNumber
|
actualTrackNum = existingMeta.TrackNumber
|
||||||
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber)
|
||||||
@@ -575,6 +564,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: actualDiscNum,
|
DiscNumber: actualDiscNum,
|
||||||
ISRC: req.ISRC,
|
ISRC: req.ISRC,
|
||||||
|
Genre: req.Genre, // From Deezer album metadata
|
||||||
|
Label: req.Label, // From Deezer album metadata
|
||||||
|
Copyright: req.Copyright, // From Deezer album metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
// Use cover data from parallel fetch
|
||||||
@@ -602,8 +594,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
|
|
||||||
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
|
||||||
|
|
||||||
// Read actual quality from the downloaded FLAC file
|
|
||||||
// Amazon API doesn't provide quality info, but we can read it from the file itself
|
|
||||||
quality, err := GetAudioQuality(outputPath)
|
quality, err := GetAudioQuality(outputPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
|
||||||
@@ -611,8 +601,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata from file AFTER embedding to get accurate values
|
|
||||||
// This ensures we return what's actually in the file
|
|
||||||
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
finalMeta, metaReadErr := ReadMetadata(outputPath)
|
||||||
if metaReadErr == nil && finalMeta != nil {
|
if metaReadErr == nil && finalMeta != nil {
|
||||||
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
|
||||||
@@ -620,7 +608,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
|
|||||||
actualTrackNum = finalMeta.TrackNumber
|
actualTrackNum = finalMeta.TrackNumber
|
||||||
actualDiscNum = finalMeta.DiscNumber
|
actualDiscNum = finalMeta.DiscNumber
|
||||||
if finalMeta.Date != "" {
|
if finalMeta.Date != "" {
|
||||||
// Use date from file if available
|
|
||||||
req.ReleaseDate = finalMeta.Date
|
req.ReleaseDate = finalMeta.Date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +15,9 @@ const (
|
|||||||
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
spotifySizeMax = "ab67616d000082c1" // Max resolution (~2000x2000)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
|
var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`)
|
||||||
|
|
||||||
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
// convertSmallToMedium upgrades 300x300 cover URL to 640x640
|
||||||
// Same logic as PC version for consistency
|
// Same logic as PC version for consistency
|
||||||
func convertSmallToMedium(imageURL string) string {
|
func convertSmallToMedium(imageURL string) string {
|
||||||
@@ -32,20 +36,19 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
|
|
||||||
GoLog("[Cover] Original URL: %s", coverURL)
|
GoLog("[Cover] Original URL: %s", coverURL)
|
||||||
|
|
||||||
// First upgrade small (300) to medium (640) - always do this
|
|
||||||
downloadURL := convertSmallToMedium(coverURL)
|
downloadURL := convertSmallToMedium(coverURL)
|
||||||
if downloadURL != coverURL {
|
if downloadURL != coverURL {
|
||||||
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
GoLog("[Cover] Upgraded 300x300 → 640x640")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then upgrade to max quality if requested
|
|
||||||
if maxQuality {
|
if maxQuality {
|
||||||
maxURL := upgradeToMaxQuality(downloadURL)
|
maxURL := upgradeToMaxQuality(downloadURL)
|
||||||
if maxURL != downloadURL {
|
if maxURL != downloadURL {
|
||||||
downloadURL = maxURL
|
downloadURL = maxURL
|
||||||
GoLog("[Cover] Upgraded to max resolution (~2000x2000)")
|
// Log already printed by upgradeToMaxQuality for Deezer
|
||||||
} else {
|
if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") {
|
||||||
GoLog("[Cover] Max resolution not available, using 640x640")
|
GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +56,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
|
|
||||||
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
client := NewHTTPClientWithTimeout(DefaultTimeout)
|
||||||
|
|
||||||
// Create request with User-Agent (required by Spotify CDN)
|
|
||||||
req, err := http.NewRequest("GET", downloadURL, nil)
|
req, err := http.NewRequest("GET", downloadURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -74,8 +76,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
return nil, fmt.Errorf("failed to read cover data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate approximate resolution from file size
|
|
||||||
// JPEG ~2000x2000 is typically 300-600KB, 640x640 is ~50-100KB
|
|
||||||
sizeKB := len(data) / 1024
|
sizeKB := len(data) / 1024
|
||||||
var resolution string
|
var resolution string
|
||||||
if sizeKB > 200 {
|
if sizeKB > 200 {
|
||||||
@@ -90,22 +90,38 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// upgradeToMaxQuality upgrades Spotify cover URL to maximum quality
|
// upgradeToMaxQuality upgrades cover URL to maximum quality
|
||||||
// Same logic as PC version - directly replaces 640x640 size code with max resolution
|
// Supports both Spotify and Deezer CDNs
|
||||||
// No HEAD verification needed - Spotify CDN always serves max resolution if available
|
|
||||||
func upgradeToMaxQuality(coverURL string) string {
|
func upgradeToMaxQuality(coverURL string) string {
|
||||||
// Spotify image URLs can be upgraded by changing the size parameter
|
// Spotify CDN upgrade
|
||||||
// Format: https://i.scdn.co/image/ab67616d0000b273...
|
|
||||||
// ab67616d0000b273 = 640x640
|
|
||||||
// ab67616d000082c1 = Max resolution (~2000x2000)
|
|
||||||
|
|
||||||
if strings.Contains(coverURL, spotifySize640) {
|
if strings.Contains(coverURL, spotifySize640) {
|
||||||
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deezer CDN upgrade
|
||||||
|
if strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
|
return upgradeDeezerCover(coverURL)
|
||||||
|
}
|
||||||
|
|
||||||
return coverURL
|
return coverURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upgradeDeezerCover upgrades Deezer cover URL to maximum quality (1800x1800)
|
||||||
|
// Deezer CDN format: https://cdn-images.dzcdn.net/images/cover/{hash}/{size}x{size}-000000-80-0-0.jpg
|
||||||
|
// Available sizes: 56, 250, 500, 1000, 1400, 1800
|
||||||
|
func upgradeDeezerCover(coverURL string) string {
|
||||||
|
if !strings.Contains(coverURL, "cdn-images.dzcdn.net") {
|
||||||
|
return coverURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace any size pattern with 1800x1800
|
||||||
|
upgraded := deezerSizeRegex.ReplaceAllString(coverURL, "/1800x1800-000000-80-0-0.jpg")
|
||||||
|
if upgraded != coverURL {
|
||||||
|
GoLog("[Cover] Deezer: upgraded to 1800x1800")
|
||||||
|
}
|
||||||
|
return upgraded
|
||||||
|
}
|
||||||
|
|
||||||
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
// GetCoverFromSpotify gets cover URL from Spotify metadata
|
||||||
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
func GetCoverFromSpotify(imageURL string, maxQuality bool) string {
|
||||||
if imageURL == "" {
|
if imageURL == "" {
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ const (
|
|||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
// Parallel ISRC fetching settings
|
deezerMaxParallelISRC = 10
|
||||||
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DeezerClient handles Deezer API interactions (no auth required)
|
// DeezerClient handles Deezer API interactions (no auth required)
|
||||||
@@ -36,7 +35,6 @@ type DeezerClient struct {
|
|||||||
cacheMu sync.RWMutex
|
cacheMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
var (
|
var (
|
||||||
deezerClient *DeezerClient
|
deezerClient *DeezerClient
|
||||||
deezerClientOnce sync.Once
|
deezerClientOnce sync.Once
|
||||||
@@ -89,11 +87,9 @@ type deezerAlbumSimple struct {
|
|||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at album level
|
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 {
|
func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
||||||
artistName := track.Artist.Name
|
artistName := track.Artist.Name
|
||||||
if len(track.Contributors) > 0 {
|
if len(track.Contributors) > 0 {
|
||||||
@@ -115,7 +111,6 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
albumImage = track.Album.Cover
|
albumImage = track.Album.Cover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find release date
|
|
||||||
releaseDate := track.ReleaseDate
|
releaseDate := track.ReleaseDate
|
||||||
if releaseDate == "" {
|
if releaseDate == "" {
|
||||||
releaseDate = track.Album.ReleaseDate
|
releaseDate = track.Album.ReleaseDate
|
||||||
@@ -137,16 +132,25 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type deezerGenre struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
type deezerAlbumFull struct {
|
type deezerAlbumFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
CoverMedium string `json:"cover_medium"`
|
CoverMedium string `json:"cover_medium"`
|
||||||
CoverBig string `json:"cover_big"`
|
CoverBig string `json:"cover_big"`
|
||||||
CoverXL string `json:"cover_xl"`
|
CoverXL string `json:"cover_xl"`
|
||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
RecordType string `json:"record_type"` // album, single, ep, compile
|
RecordType string `json:"record_type"` // album, single, ep, compile
|
||||||
|
Label string `json:"label"` // Record label name
|
||||||
|
Genres struct {
|
||||||
|
Data []deezerGenre `json:"data"`
|
||||||
|
} `json:"genres"`
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
@@ -315,12 +319,23 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
artistName = strings.Join(names, ", ")
|
artistName = strings.Join(names, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract genres as comma-separated string
|
||||||
|
var genres []string
|
||||||
|
for _, g := range album.Genres.Data {
|
||||||
|
if g.Name != "" {
|
||||||
|
genres = append(genres, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
genreStr := strings.Join(genres, ", ")
|
||||||
|
|
||||||
info := AlbumInfoMetadata{
|
info := AlbumInfoMetadata{
|
||||||
TotalTracks: album.NbTracks,
|
TotalTracks: album.NbTracks,
|
||||||
Name: album.Title,
|
Name: album.Title,
|
||||||
ReleaseDate: album.ReleaseDate,
|
ReleaseDate: album.ReleaseDate,
|
||||||
Artists: artistName,
|
Artists: artistName,
|
||||||
Images: albumImage,
|
Images: albumImage,
|
||||||
|
Genre: genreStr, // From Deezer album
|
||||||
|
Label: album.Label, // From Deezer album
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ISRCs in parallel
|
// Fetch ISRCs in parallel
|
||||||
@@ -543,7 +558,6 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
|||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we got a valid response (ID > 0)
|
|
||||||
if track.ID == 0 {
|
if track.ID == 0 {
|
||||||
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
return nil, fmt.Errorf("no track found for ISRC: %s", isrc)
|
||||||
}
|
}
|
||||||
@@ -566,7 +580,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
// First, check cache for existing ISRCs
|
|
||||||
var tracksToFetch []deezerTrack
|
var tracksToFetch []deezerTrack
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
for _, track := range tracks {
|
for _, track := range tracks {
|
||||||
@@ -624,7 +637,6 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
// GetTrackISRC fetches ISRC for a single track (with caching)
|
// GetTrackISRC fetches ISRC for a single track (with caching)
|
||||||
// Use this when you need ISRC for download
|
// Use this when you need ISRC for download
|
||||||
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if isrc, ok := c.isrcCache[trackID]; ok {
|
if isrc, ok := c.isrcCache[trackID]; ok {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -685,6 +697,84 @@ func (c *DeezerClient) getBestAlbumImage(album deezerAlbumFull) string {
|
|||||||
return album.Cover
|
return album.Cover
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlbumExtendedMetadata contains genre and label information from an album
|
||||||
|
type AlbumExtendedMetadata struct {
|
||||||
|
Genre string // Comma-separated list of genres
|
||||||
|
Label string // Record label name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAlbumExtendedMetadata fetches genre and label from a Deezer album
|
||||||
|
// Uses the album ID from a track to fetch extended metadata
|
||||||
|
func (c *DeezerClient) GetAlbumExtendedMetadata(ctx context.Context, albumID string) (*AlbumExtendedMetadata, error) {
|
||||||
|
if albumID == "" {
|
||||||
|
return nil, fmt.Errorf("empty album ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
cacheKey := fmt.Sprintf("album_meta:%s", albumID)
|
||||||
|
c.cacheMu.RLock()
|
||||||
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
return entry.data.(*AlbumExtendedMetadata), nil
|
||||||
|
}
|
||||||
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
|
var album deezerAlbumFull
|
||||||
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch album: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract genres as comma-separated string
|
||||||
|
var genres []string
|
||||||
|
for _, g := range album.Genres.Data {
|
||||||
|
if g.Name != "" {
|
||||||
|
genres = append(genres, g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &AlbumExtendedMetadata{
|
||||||
|
Genre: strings.Join(genres, ", "),
|
||||||
|
Label: album.Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
c.cacheMu.Lock()
|
||||||
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
|
data: result,
|
||||||
|
expiresAt: time.Now().Add(deezerCacheTTL),
|
||||||
|
}
|
||||||
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
|
GoLog("[Deezer] Album metadata fetched - Genre: %s, Label: %s\n", result.Genre, result.Label)
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrackAlbumID fetches the album ID for a Deezer track
|
||||||
|
func (c *DeezerClient) GetTrackAlbumID(ctx context.Context, trackID string) (string, error) {
|
||||||
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
|
var track deezerTrack
|
||||||
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", track.Album.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExtendedMetadataByTrackID fetches genre and label using a Deezer track ID
|
||||||
|
// This is a convenience function that first gets the album ID, then fetches album metadata
|
||||||
|
func (c *DeezerClient) GetExtendedMetadataByTrackID(ctx context.Context, trackID string) (*AlbumExtendedMetadata, error) {
|
||||||
|
albumID, err := c.GetTrackAlbumID(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get album ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.GetAlbumExtendedMetadata(ctx, albumID)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -18,30 +18,45 @@ type ISRCIndex struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global ISRC index cache (per output directory)
|
|
||||||
var (
|
var (
|
||||||
isrcIndexCache = make(map[string]*ISRCIndex)
|
isrcIndexCache = make(map[string]*ISRCIndex)
|
||||||
isrcIndexCacheMu sync.RWMutex
|
isrcIndexCacheMu sync.RWMutex
|
||||||
isrcIndexTTL = 5 * time.Minute // Cache TTL - rebuild after 5 minutes
|
isrcBuildingMu sync.Map // Per-directory build lock to prevent concurrent builds
|
||||||
|
isrcIndexTTL = 5 * time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetISRCIndex returns or builds an ISRC index for the given directory
|
// GetISRCIndex returns or builds an ISRC index for the given directory
|
||||||
|
// Uses per-directory mutex to prevent concurrent builds (race condition fix)
|
||||||
func GetISRCIndex(outputDir string) *ISRCIndex {
|
func GetISRCIndex(outputDir string) *ISRCIndex {
|
||||||
|
// Fast path: check cache first
|
||||||
isrcIndexCacheMu.RLock()
|
isrcIndexCacheMu.RLock()
|
||||||
idx, exists := isrcIndexCache[outputDir]
|
idx, exists := isrcIndexCache[outputDir]
|
||||||
isrcIndexCacheMu.RUnlock()
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
// Return cached index if still valid
|
|
||||||
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build new index
|
// Slow path: need to build index
|
||||||
|
// Use per-directory mutex to prevent multiple goroutines from building simultaneously
|
||||||
|
buildLock, _ := isrcBuildingMu.LoadOrStore(outputDir, &sync.Mutex{})
|
||||||
|
mu := buildLock.(*sync.Mutex)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
// Double-check cache after acquiring lock (another goroutine may have built it)
|
||||||
|
isrcIndexCacheMu.RLock()
|
||||||
|
idx, exists = isrcIndexCache[outputDir]
|
||||||
|
isrcIndexCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if exists && time.Since(idx.buildTime) < isrcIndexTTL {
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
return buildISRCIndex(outputDir)
|
return buildISRCIndex(outputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
// buildISRCIndex scans a directory and builds a map of ISRC -> file path
|
||||||
// Same implementation as PC version for consistency
|
|
||||||
func buildISRCIndex(outputDir string) *ISRCIndex {
|
func buildISRCIndex(outputDir string) *ISRCIndex {
|
||||||
idx := &ISRCIndex{
|
idx := &ISRCIndex{
|
||||||
index: make(map[string]string),
|
index: make(map[string]string),
|
||||||
@@ -56,7 +71,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
fileCount := 0
|
fileCount := 0
|
||||||
|
|
||||||
// Walk directory - only check .flac files
|
|
||||||
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
filepath.Walk(outputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil || info.IsDir() {
|
if err != nil || info.IsDir() {
|
||||||
return nil
|
return nil
|
||||||
@@ -67,13 +81,11 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read ISRC from file
|
|
||||||
metadata, err := ReadMetadata(path)
|
metadata, err := ReadMetadata(path)
|
||||||
if err != nil || metadata.ISRC == "" {
|
if err != nil || metadata.ISRC == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in index (uppercase for case-insensitive matching)
|
|
||||||
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
idx.index[strings.ToUpper(metadata.ISRC)] = path
|
||||||
fileCount++
|
fileCount++
|
||||||
return nil
|
return nil
|
||||||
@@ -82,7 +94,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
fmt.Printf("[ISRCIndex] Built index for %s: %d files in %v\n",
|
||||||
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
outputDir, fileCount, time.Since(startTime).Round(time.Millisecond))
|
||||||
|
|
||||||
// Cache the index
|
|
||||||
isrcIndexCacheMu.Lock()
|
isrcIndexCacheMu.Lock()
|
||||||
isrcIndexCache[outputDir] = idx
|
isrcIndexCache[outputDir] = idx
|
||||||
isrcIndexCacheMu.Unlock()
|
isrcIndexCacheMu.Unlock()
|
||||||
@@ -90,7 +101,6 @@ func buildISRCIndex(outputDir string) *ISRCIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookup checks if an ISRC exists in the index (internal, returns bool)
|
|
||||||
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
||||||
if isrc == "" {
|
if isrc == "" {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -103,6 +113,18 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) {
|
|||||||
return path, exists
|
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)
|
// Lookup checks if an ISRC exists in the index (gomobile compatible)
|
||||||
// Returns filepath if found, empty string if not found
|
// Returns filepath if found, empty string if not found
|
||||||
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
func (idx *ISRCIndex) Lookup(isrc string) (string, error) {
|
||||||
@@ -138,7 +160,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) {
|
|||||||
|
|
||||||
// Use index for fast lookup
|
// Use index for fast lookup
|
||||||
idx := GetISRCIndex(outputDir)
|
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)
|
// CheckISRCExists is the exported version for gomobile (returns string, error)
|
||||||
@@ -170,7 +203,6 @@ type FileExistenceResult struct {
|
|||||||
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
// It builds an ISRC index from the output directory once, then checks all tracks against it
|
||||||
// Same implementation as PC version for consistency
|
// Same implementation as PC version for consistency
|
||||||
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error) {
|
||||||
// Parse input JSON
|
|
||||||
var tracks []struct {
|
var tracks []struct {
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
TrackName string `json:"track_name"`
|
TrackName string `json:"track_name"`
|
||||||
@@ -182,10 +214,8 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
|
|
||||||
results := make([]FileExistenceResult, len(tracks))
|
results := make([]FileExistenceResult, len(tracks))
|
||||||
|
|
||||||
// Build ISRC index from output directory (scan once)
|
|
||||||
isrcIdx := GetISRCIndex(outputDir)
|
isrcIdx := GetISRCIndex(outputDir)
|
||||||
|
|
||||||
// Check each track against the index (parallel)
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
@@ -216,7 +246,6 @@ func CheckFilesExistParallel(outputDir string, tracksJSON string) (string, error
|
|||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Return results as JSON
|
|
||||||
resultJSON, err := json.Marshal(results)
|
resultJSON, err := json.Marshal(results)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to marshal results: %w", err)
|
return "", fmt.Errorf("failed to marshal results: %w", err)
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseSpotifyURL parses and validates a Spotify URL
|
// ParseSpotifyURL parses and validates a Spotify URL
|
||||||
@@ -150,6 +153,14 @@ type DownloadRequest struct {
|
|||||||
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
ItemID string `json:"item_id"` // Unique ID for progress tracking
|
||||||
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
DurationMS int `json:"duration_ms"` // Expected duration in milliseconds (for verification)
|
||||||
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
Source string `json:"source"` // Extension ID that provided this track (prioritize this extension)
|
||||||
|
// Extended metadata from Deezer for FLAC tagging
|
||||||
|
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated
|
||||||
|
Label string `json:"label,omitempty"` // Record label name
|
||||||
|
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||||
|
// 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
|
// DownloadResponse represents the result of a download
|
||||||
@@ -177,7 +188,6 @@ type DownloadResponse struct {
|
|||||||
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadResult is a generic result type for all downloaders
|
|
||||||
// DownloadResult is a generic result type for all downloaders
|
// DownloadResult is a generic result type for all downloaders
|
||||||
type DownloadResult struct {
|
type DownloadResult struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -276,10 +286,8 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return errorResponse(err.Error())
|
return errorResponse(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
// Read actual quality from existing file
|
|
||||||
quality, qErr := GetAudioQuality(actualPath)
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -305,7 +313,6 @@ func DownloadTrack(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -355,7 +362,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
AddAllowedDownloadDir(req.OutputDir)
|
AddAllowedDownloadDir(req.OutputDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build service order starting with preferred service
|
|
||||||
allServices := []string{"tidal", "qobuz", "amazon"}
|
allServices := []string{"tidal", "qobuz", "amazon"}
|
||||||
preferredService := req.Service
|
preferredService := req.Service
|
||||||
if preferredService == "" {
|
if preferredService == "" {
|
||||||
@@ -364,7 +370,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
|
|
||||||
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
GoLog("[DownloadWithFallback] Preferred service from request: '%s'\n", req.Service)
|
||||||
|
|
||||||
// Create ordered list: preferred first, then others
|
|
||||||
services := []string{preferredService}
|
services := []string{preferredService}
|
||||||
for _, s := range allServices {
|
for _, s := range allServices {
|
||||||
if s != preferredService {
|
if s != preferredService {
|
||||||
@@ -399,7 +404,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
DiscNumber: tidalResult.DiscNumber,
|
DiscNumber: tidalResult.DiscNumber,
|
||||||
ISRC: tidalResult.ISRC,
|
ISRC: tidalResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else if !errors.Is(tidalErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr)
|
||||||
}
|
}
|
||||||
err = tidalErr
|
err = tidalErr
|
||||||
@@ -418,7 +423,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
DiscNumber: qobuzResult.DiscNumber,
|
DiscNumber: qobuzResult.DiscNumber,
|
||||||
ISRC: qobuzResult.ISRC,
|
ISRC: qobuzResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr)
|
||||||
}
|
}
|
||||||
err = qobuzErr
|
err = qobuzErr
|
||||||
@@ -437,17 +442,19 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
DiscNumber: amazonResult.DiscNumber,
|
DiscNumber: amazonResult.DiscNumber,
|
||||||
ISRC: amazonResult.ISRC,
|
ISRC: amazonResult.ISRC,
|
||||||
}
|
}
|
||||||
} else {
|
} else if !errors.Is(amazonErr, ErrDownloadCancelled) {
|
||||||
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
|
||||||
}
|
}
|
||||||
err = amazonErr
|
err = amazonErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err != nil && errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return errorResponse("Download cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Check if file already exists
|
|
||||||
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" {
|
||||||
actualPath := result.FilePath[7:]
|
actualPath := result.FilePath[7:]
|
||||||
// Read actual quality from existing file
|
|
||||||
quality, qErr := GetAudioQuality(actualPath)
|
quality, qErr := GetAudioQuality(actualPath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -473,7 +480,6 @@ func DownloadWithFallback(requestJSON string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read actual quality from downloaded file (more accurate than API)
|
|
||||||
quality, qErr := GetAudioQuality(result.FilePath)
|
quality, qErr := GetAudioQuality(result.FilePath)
|
||||||
if qErr == nil {
|
if qErr == nil {
|
||||||
result.BitDepth = quality.BitDepth
|
result.BitDepth = quality.BitDepth
|
||||||
@@ -528,7 +534,6 @@ func InitItemProgress(itemID string) {
|
|||||||
// FinishItemProgress marks a download item as complete and removes tracking
|
// FinishItemProgress marks a download item as complete and removes tracking
|
||||||
func FinishItemProgress(itemID string) {
|
func FinishItemProgress(itemID string) {
|
||||||
CompleteItemProgress(itemID)
|
CompleteItemProgress(itemID)
|
||||||
// Don't remove immediately - let Flutter poll one more time to see 100%
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearItemProgress removes progress tracking for a specific item
|
// ClearItemProgress removes progress tracking for a specific item
|
||||||
@@ -536,6 +541,11 @@ func ClearItemProgress(itemID string) {
|
|||||||
RemoveItemProgress(itemID)
|
RemoveItemProgress(itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CancelDownload cancels an in-progress download for the given item.
|
||||||
|
func CancelDownload(itemID string) {
|
||||||
|
cancelDownload(itemID)
|
||||||
|
}
|
||||||
|
|
||||||
// CleanupConnections closes idle HTTP connections
|
// CleanupConnections closes idle HTTP connections
|
||||||
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
// Call this periodically during large batch downloads to prevent TCP exhaustion
|
||||||
func CleanupConnections() {
|
func CleanupConnections() {
|
||||||
@@ -551,10 +561,8 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to read metadata: %w", err)
|
return "", fmt.Errorf("failed to read metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also get audio quality info
|
|
||||||
quality, qualityErr := GetAudioQuality(filePath)
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
|
|
||||||
// Get duration from FLAC stream info
|
|
||||||
duration := 0
|
duration := 0
|
||||||
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
@@ -573,7 +581,6 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
"duration": duration,
|
"duration": duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add quality info if available
|
|
||||||
if qualityErr == nil {
|
if qualityErr == nil {
|
||||||
result["bit_depth"] = quality.BitDepth
|
result["bit_depth"] = quality.BitDepth
|
||||||
result["sample_rate"] = quality.SampleRate
|
result["sample_rate"] = quality.SampleRate
|
||||||
@@ -624,7 +631,6 @@ func PreBuildDuplicateIndex(outputDir string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
// InvalidateDuplicateIndex clears the ISRC index cache for a directory
|
||||||
// Call this when files are deleted or moved
|
|
||||||
func InvalidateDuplicateIndex(outputDir string) {
|
func InvalidateDuplicateIndex(outputDir string) {
|
||||||
InvalidateISRCCache(outputDir)
|
InvalidateISRCCache(outputDir)
|
||||||
}
|
}
|
||||||
@@ -647,9 +653,11 @@ func SanitizeFilename(filename string) string {
|
|||||||
|
|
||||||
// FetchLyrics fetches lyrics for a track from LRCLIB
|
// FetchLyrics fetches lyrics for a track from LRCLIB
|
||||||
// Returns JSON with lyrics data
|
// Returns JSON with lyrics data
|
||||||
func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
|
||||||
|
func FetchLyrics(spotifyID, trackName, artistName string, durationMs int64) (string, error) {
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -671,8 +679,8 @@ func FetchLyrics(spotifyID, trackName, artistName string) (string, error) {
|
|||||||
|
|
||||||
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
// GetLyricsLRC fetches lyrics and converts to LRC format string with metadata headers
|
||||||
// First tries to extract from file, then falls back to fetching from internet
|
// First tries to extract from file, then falls back to fetching from internet
|
||||||
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (string, error) {
|
// durationMs: track duration in milliseconds for matching, use 0 to skip duration matching
|
||||||
// Try to extract from file first (much faster)
|
func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string, durationMs int64) (string, error) {
|
||||||
if filePath != "" {
|
if filePath != "" {
|
||||||
lyrics, err := ExtractLyrics(filePath)
|
lyrics, err := ExtractLyrics(filePath)
|
||||||
if err == nil && lyrics != "" {
|
if err == nil && lyrics != "" {
|
||||||
@@ -680,14 +688,13 @@ func GetLyricsLRC(spotifyID, trackName, artistName string, filePath string) (str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to fetching from internet
|
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyricsData, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to LRC format with metadata headers (like PC version)
|
|
||||||
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
lrcContent := convertToLRCWithMetadata(lyricsData, trackName, artistName)
|
||||||
return lrcContent, nil
|
return lrcContent, nil
|
||||||
}
|
}
|
||||||
@@ -724,7 +731,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
|||||||
return errorResponse("Invalid JSON: " + err.Error())
|
return errorResponse("Invalid JSON: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to PreWarmCacheRequest
|
|
||||||
requests := make([]PreWarmCacheRequest, len(tracks))
|
requests := make([]PreWarmCacheRequest, len(tracks))
|
||||||
for i, t := range tracks {
|
for i, t := range tracks {
|
||||||
requests[i] = PreWarmCacheRequest{
|
requests[i] = PreWarmCacheRequest{
|
||||||
@@ -736,7 +742,6 @@ func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run in background
|
|
||||||
go PreWarmTrackCache(requests)
|
go PreWarmTrackCache(requests)
|
||||||
|
|
||||||
resp := map[string]interface{}{
|
resp := map[string]interface{}{
|
||||||
@@ -836,6 +841,37 @@ func ParseDeezerURLExport(url string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDeezerExtendedMetadata fetches genre and label from Deezer album
|
||||||
|
// trackID: Deezer track ID (will look up album ID from track)
|
||||||
|
// Returns JSON with genre, label fields
|
||||||
|
func GetDeezerExtendedMetadata(trackID string) (string, error) {
|
||||||
|
if trackID == "" {
|
||||||
|
return "", fmt.Errorf("empty track ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := GetDeezerClient()
|
||||||
|
metadata, err := client.GetExtendedMetadataByTrackID(ctx, trackID)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Deezer] Failed to get extended metadata: %v\n", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := map[string]string{
|
||||||
|
"genre": metadata.Genre,
|
||||||
|
"label": metadata.Label,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
// SearchDeezerByISRC searches for a track by ISRC on Deezer
|
||||||
func SearchDeezerByISRC(isrc string) (string, error) {
|
func SearchDeezerByISRC(isrc string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
@@ -856,7 +892,6 @@ func SearchDeezerByISRC(isrc string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
// ConvertSpotifyToDeezer converts a Spotify track/album ID to Deezer and fetches metadata
|
||||||
// This uses SongLink API to find the Deezer equivalent, then fetches from Deezer
|
|
||||||
// Useful when Spotify API is rate limited
|
// Useful when Spotify API is rate limited
|
||||||
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
@@ -865,14 +900,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
songlink := NewSongLinkClient()
|
songlink := NewSongLinkClient()
|
||||||
deezerClient := GetDeezerClient()
|
deezerClient := GetDeezerClient()
|
||||||
|
|
||||||
// For tracks, we can use SongLink to get Deezer ID
|
|
||||||
if resourceType == "track" {
|
if resourceType == "track" {
|
||||||
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
return "", fmt.Errorf("could not find Deezer equivalent: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch metadata from Deezer
|
|
||||||
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer metadata: %w", err)
|
||||||
@@ -886,14 +919,12 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// For albums, SongLink also provides mapping
|
|
||||||
if resourceType == "album" {
|
if resourceType == "album" {
|
||||||
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
deezerID, err := songlink.GetDeezerAlbumIDFromSpotify(spotifyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
return "", fmt.Errorf("could not find Deezer album: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch album metadata from Deezer
|
|
||||||
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
albumResp, err := deezerClient.GetAlbum(ctx, deezerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
return "", fmt.Errorf("failed to fetch Deezer album metadata: %w", err)
|
||||||
@@ -916,10 +947,8 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Try Spotify first
|
|
||||||
client, err := NewSpotifyMetadataClient()
|
client, err := NewSpotifyMetadataClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// No Spotify credentials - fall through to Deezer fallback
|
|
||||||
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
|
||||||
} else {
|
} else {
|
||||||
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
|
||||||
@@ -931,15 +960,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
return string(jsonBytes), nil
|
return string(jsonBytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a rate limit error
|
|
||||||
errStr := strings.ToLower(err.Error())
|
errStr := strings.ToLower(err.Error())
|
||||||
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") {
|
||||||
// Not a rate limit error, return original error
|
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limited - try Deezer fallback for tracks and albums
|
|
||||||
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
parsed, parseErr := parseSpotifyURI(spotifyURL)
|
||||||
if parseErr != nil {
|
if parseErr != nil {
|
||||||
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr)
|
||||||
@@ -948,11 +974,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
|
|||||||
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type)
|
||||||
|
|
||||||
if parsed.Type == "track" || parsed.Type == "album" {
|
if parsed.Type == "track" || parsed.Type == "album" {
|
||||||
// Convert to Deezer
|
|
||||||
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist and playlist not supported for fallback
|
|
||||||
if parsed.Type == "artist" {
|
if parsed.Type == "artist" {
|
||||||
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later")
|
||||||
}
|
}
|
||||||
@@ -1017,7 +1041,6 @@ func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func errorResponse(msg string) (string, error) {
|
func errorResponse(msg string) (string, error) {
|
||||||
// Determine error type based on message
|
|
||||||
errorType := "unknown"
|
errorType := "unknown"
|
||||||
lowerMsg := strings.ToLower(msg)
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
@@ -1025,6 +1048,8 @@ func errorResponse(msg string) (string, error) {
|
|||||||
strings.Contains(lowerMsg, "try using vpn") ||
|
strings.Contains(lowerMsg, "try using vpn") ||
|
||||||
strings.Contains(lowerMsg, "change dns") {
|
strings.Contains(lowerMsg, "change dns") {
|
||||||
errorType = "isp_blocked"
|
errorType = "isp_blocked"
|
||||||
|
} else if strings.Contains(lowerMsg, "cancel") {
|
||||||
|
errorType = "cancelled"
|
||||||
} else if strings.Contains(lowerMsg, "permission") ||
|
} else if strings.Contains(lowerMsg, "permission") ||
|
||||||
strings.Contains(lowerMsg, "operation not permitted") ||
|
strings.Contains(lowerMsg, "operation not permitted") ||
|
||||||
strings.Contains(lowerMsg, "access denied") ||
|
strings.Contains(lowerMsg, "access denied") ||
|
||||||
@@ -1104,7 +1129,6 @@ func LoadExtensionFromPath(filePath string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize with saved settings
|
|
||||||
settingsStore := GetExtensionSettingsStore()
|
settingsStore := GetExtensionSettingsStore()
|
||||||
settings := settingsStore.GetAll(ext.ID)
|
settings := settingsStore.GetAll(ext.ID)
|
||||||
if len(settings) > 0 {
|
if len(settings) > 0 {
|
||||||
@@ -1255,7 +1279,6 @@ func SetExtensionSettingsJSON(extensionID, settingsJSON string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-initialize extension with new settings
|
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
return manager.InitializeExtension(extensionID, settings)
|
return manager.InitializeExtension(extensionID, settings)
|
||||||
}
|
}
|
||||||
@@ -1302,6 +1325,23 @@ func CleanupExtensions() {
|
|||||||
manager.UnloadAllExtensions()
|
manager.UnloadAllExtensions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvokeExtensionActionJSON invokes a custom action on an extension (e.g., button click handler)
|
||||||
|
// actionName is the JS function name to call (e.g., "startLogin", "authenticate", etc.)
|
||||||
|
func InvokeExtensionActionJSON(extensionID, actionName string) (string, error) {
|
||||||
|
manager := GetExtensionManager()
|
||||||
|
result, err := manager.InvokeAction(extensionID, actionName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(jsonBytes), nil
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== EXTENSION AUTH API ====================
|
// ==================== EXTENSION AUTH API ====================
|
||||||
|
|
||||||
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
// GetExtensionPendingAuthJSON returns pending auth request for an extension
|
||||||
@@ -1354,7 +1394,6 @@ func IsExtensionAuthenticatedByID(extensionID string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
if state.IsAuthenticated && !state.ExpiresAt.IsZero() && time.Now().After(state.ExpiresAt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1451,7 +1490,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !ext.Manifest.IsMetadataProvider() {
|
if !ext.Manifest.IsMetadataProvider() {
|
||||||
// Not a metadata provider, return original
|
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1463,7 +1501,6 @@ func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error)
|
|||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
enrichedTrack, err := provider.EnrichTrack(&track)
|
enrichedTrack, err := provider.EnrichTrack(&track)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Error enriching, return original
|
|
||||||
return trackJSON, nil
|
return trackJSON, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1500,7 +1537,6 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to map format for Flutter, ensuring images field is set
|
|
||||||
result := make([]map[string]interface{}, len(tracks))
|
result := make([]map[string]interface{}, len(tracks))
|
||||||
for i, track := range tracks {
|
for i, track := range tracks {
|
||||||
result[i] = map[string]interface{}{
|
result[i] = map[string]interface{}{
|
||||||
@@ -1516,6 +1552,8 @@ func CustomSearchWithExtensionJSON(extensionID, query string, optionsJSON string
|
|||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
|
"item_type": track.ItemType, // track, album, or playlist
|
||||||
|
"album_type": track.AlbumType, // album, single, ep, compilation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1565,12 +1603,10 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
result := resultWithID.Result
|
result := resultWithID.Result
|
||||||
extensionID := resultWithID.ExtensionID
|
extensionID := resultWithID.ExtensionID
|
||||||
|
|
||||||
// Check if result is nil (handler found but returned error)
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
return "", fmt.Errorf("extension %s failed to handle URL", extensionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build response
|
|
||||||
response := map[string]interface{}{
|
response := map[string]interface{}{
|
||||||
"type": result.Type,
|
"type": result.Type,
|
||||||
"extension_id": extensionID,
|
"extension_id": extensionID,
|
||||||
@@ -1578,7 +1614,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"cover_url": result.CoverURL,
|
"cover_url": result.CoverURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add track if single track
|
|
||||||
if result.Track != nil {
|
if result.Track != nil {
|
||||||
response["track"] = map[string]interface{}{
|
response["track"] = map[string]interface{}{
|
||||||
"id": result.Track.ID,
|
"id": result.Track.ID,
|
||||||
@@ -1596,7 +1631,6 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tracks if multiple
|
|
||||||
if len(result.Tracks) > 0 {
|
if len(result.Tracks) > 0 {
|
||||||
tracks := make([]map[string]interface{}, len(result.Tracks))
|
tracks := make([]map[string]interface{}, len(result.Tracks))
|
||||||
for i, track := range result.Tracks {
|
for i, track := range result.Tracks {
|
||||||
@@ -1613,6 +1647,8 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"disc_number": track.DiscNumber,
|
"disc_number": track.DiscNumber,
|
||||||
"isrc": track.ISRC,
|
"isrc": track.ISRC,
|
||||||
"provider_id": track.ProviderID,
|
"provider_id": track.ProviderID,
|
||||||
|
"item_type": track.ItemType,
|
||||||
|
"album_type": track.AlbumType,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response["tracks"] = tracks
|
response["tracks"] = tracks
|
||||||
@@ -1627,18 +1663,21 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"cover_url": result.Album.CoverURL,
|
"cover_url": result.Album.CoverURL,
|
||||||
"release_date": result.Album.ReleaseDate,
|
"release_date": result.Album.ReleaseDate,
|
||||||
"total_tracks": result.Album.TotalTracks,
|
"total_tracks": result.Album.TotalTracks,
|
||||||
|
"album_type": result.Album.AlbumType,
|
||||||
|
"provider_id": result.Album.ProviderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add artist info if present
|
|
||||||
if result.Artist != nil {
|
if result.Artist != nil {
|
||||||
artistResponse := map[string]interface{}{
|
artistResponse := map[string]interface{}{
|
||||||
"id": result.Artist.ID,
|
"id": result.Artist.ID,
|
||||||
"name": result.Artist.Name,
|
"name": result.Artist.Name,
|
||||||
"image_url": result.Artist.ImageURL,
|
"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 {
|
if len(result.Artist.Albums) > 0 {
|
||||||
albums := make([]map[string]interface{}, len(result.Artist.Albums))
|
albums := make([]map[string]interface{}, len(result.Artist.Albums))
|
||||||
for i, album := range result.Artist.Albums {
|
for i, album := range result.Artist.Albums {
|
||||||
@@ -1651,14 +1690,38 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||||||
"name": album.Name,
|
"name": album.Name,
|
||||||
"artists": album.Artists,
|
"artists": album.Artists,
|
||||||
"images": album.CoverURL,
|
"images": album.CoverURL,
|
||||||
|
"cover_url": album.CoverURL,
|
||||||
"release_date": album.ReleaseDate,
|
"release_date": album.ReleaseDate,
|
||||||
"total_tracks": album.TotalTracks,
|
"total_tracks": album.TotalTracks,
|
||||||
"album_type": albumType,
|
"album_type": albumType,
|
||||||
|
"provider_id": album.ProviderID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
artistResponse["albums"] = albums
|
artistResponse["albums"] = albums
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
response["artist"] = artistResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1681,6 +1744,253 @@ func FindURLHandlerJSON(url string) string {
|
|||||||
return handler.extension.ID
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||||
|
for i, track := range album.Tracks {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
tracks := make([]map[string]interface{}, len(album.Tracks))
|
||||||
|
for i, track := range album.Tracks {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// GetURLHandlersJSON returns all extensions that handle custom URLs
|
||||||
func GetURLHandlersJSON() (string, error) {
|
func GetURLHandlersJSON() (string, error) {
|
||||||
manager := GetExtensionManager()
|
manager := GetExtensionManager()
|
||||||
|
|||||||
@@ -18,11 +18,9 @@ import (
|
|||||||
// compareVersions compares two semantic version strings
|
// compareVersions compares two semantic version strings
|
||||||
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2
|
||||||
func compareVersions(v1, v2 string) int {
|
func compareVersions(v1, v2 string) int {
|
||||||
// Parse version parts
|
|
||||||
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
parts1 := strings.Split(strings.TrimPrefix(v1, "v"), ".")
|
||||||
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
parts2 := strings.Split(strings.TrimPrefix(v2, "v"), ".")
|
||||||
|
|
||||||
// Pad shorter version with zeros
|
|
||||||
maxLen := len(parts1)
|
maxLen := len(parts1)
|
||||||
if len(parts2) > maxLen {
|
if len(parts2) > maxLen {
|
||||||
maxLen = len(parts2)
|
maxLen = len(parts2)
|
||||||
@@ -52,12 +50,12 @@ func compareVersions(v1, v2 string) int {
|
|||||||
type LoadedExtension struct {
|
type LoadedExtension struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Manifest *ExtensionManifest `json:"manifest"`
|
Manifest *ExtensionManifest `json:"manifest"`
|
||||||
VM *goja.Runtime `json:"-"` // Goja VM instance (not serialized)
|
VM *goja.Runtime `json:"-"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
DataDir string `json:"data_dir"` // Extension's data directory
|
DataDir string `json:"data_dir"`
|
||||||
SourceDir string `json:"source_dir"` // Where extension files are extracted
|
SourceDir string `json:"source_dir"`
|
||||||
IconPath string `json:"icon_path"` // Full path to icon file (if exists)
|
IconPath string `json:"icon_path"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtensionManager manages all loaded extensions
|
// ExtensionManager manages all loaded extensions
|
||||||
@@ -68,7 +66,6 @@ type ExtensionManager struct {
|
|||||||
dataDir string // Base directory for extension data
|
dataDir string // Base directory for extension data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global extension manager instance
|
|
||||||
var (
|
var (
|
||||||
globalExtManager *ExtensionManager
|
globalExtManager *ExtensionManager
|
||||||
globalExtManagerOnce sync.Once
|
globalExtManagerOnce sync.Once
|
||||||
@@ -92,7 +89,6 @@ func (m *ExtensionManager) SetDirectories(extensionsDir, dataDir string) error {
|
|||||||
m.extensionsDir = extensionsDir
|
m.extensionsDir = extensionsDir
|
||||||
m.dataDir = dataDir
|
m.dataDir = dataDir
|
||||||
|
|
||||||
// Create directories if they don't exist
|
|
||||||
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
if err := os.MkdirAll(extensionsDir, 0755); err != nil {
|
||||||
return fmt.Errorf("failed to create extensions directory: %w", err)
|
return fmt.Errorf("failed to create extensions directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -117,7 +113,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
var hasIndexJS bool
|
var hasIndexJS bool
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
@@ -146,13 +141,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
manifest, err := ParseManifest(manifestData)
|
manifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension already loaded - if so, try upgrade (check without holding lock for long)
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[manifest.Name]
|
existing, exists := m.extensions[manifest.Name]
|
||||||
var existingVersion string
|
var existingVersion string
|
||||||
@@ -164,7 +157,6 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
// Check if this is an upgrade
|
|
||||||
versionCompare := compareVersions(manifest.Version, existingVersion)
|
versionCompare := compareVersions(manifest.Version, existingVersion)
|
||||||
if versionCompare > 0 {
|
if versionCompare > 0 {
|
||||||
// This is an upgrade - call UpgradeExtension
|
// This is an upgrade - call UpgradeExtension
|
||||||
@@ -176,16 +168,13 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now acquire write lock for the rest of the operation
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Double-check extension wasn't added while we were waiting for lock
|
|
||||||
if _, exists := m.extensions[manifest.Name]; exists {
|
if _, exists := m.extensions[manifest.Name]; exists {
|
||||||
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
return nil, fmt.Errorf("Extension '%s' was installed by another process", manifest.DisplayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extension directory
|
|
||||||
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
extDir := filepath.Join(m.extensionsDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
@@ -206,19 +195,16 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
destPath := filepath.Join(extDir, relPath)
|
destPath := filepath.Join(extDir, relPath)
|
||||||
|
|
||||||
// Create parent directories if needed
|
|
||||||
destDir := filepath.Dir(destPath)
|
destDir := filepath.Dir(destPath)
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination file
|
|
||||||
destFile, err := os.Create(destPath)
|
destFile, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy content
|
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
destFile.Close()
|
destFile.Close()
|
||||||
@@ -233,13 +219,11 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory for extension
|
|
||||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create loaded extension
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
@@ -263,23 +247,19 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens
|
|||||||
|
|
||||||
// initializeVM creates and initializes the Goja VM for an extension
|
// initializeVM creates and initializes the Goja VM for an extension
|
||||||
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
||||||
// Create new Goja runtime
|
|
||||||
vm := goja.New()
|
vm := goja.New()
|
||||||
ext.VM = vm
|
ext.VM = vm
|
||||||
|
|
||||||
// Read index.js
|
|
||||||
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
indexPath := filepath.Join(ext.SourceDir, "index.js")
|
||||||
jsCode, err := os.ReadFile(indexPath)
|
jsCode, err := os.ReadFile(indexPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read index.js: %w", err)
|
return fmt.Errorf("failed to read index.js: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create extension runtime and register sandboxed APIs
|
|
||||||
runtime := NewExtensionRuntime(ext)
|
runtime := NewExtensionRuntime(ext)
|
||||||
runtime.RegisterAPIs(vm)
|
runtime.RegisterAPIs(vm)
|
||||||
runtime.RegisterGoBackendAPIs(vm)
|
runtime.RegisterGoBackendAPIs(vm)
|
||||||
|
|
||||||
// Set up console.log for debugging
|
|
||||||
console := vm.NewObject()
|
console := vm.NewObject()
|
||||||
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
console.Set("log", func(call goja.FunctionCall) goja.Value {
|
||||||
args := make([]interface{}, len(call.Arguments))
|
args := make([]interface{}, len(call.Arguments))
|
||||||
@@ -291,12 +271,10 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error {
|
|||||||
})
|
})
|
||||||
vm.Set("console", console)
|
vm.Set("console", console)
|
||||||
|
|
||||||
// Set up registerExtension function
|
|
||||||
var registeredExtension goja.Value
|
var registeredExtension goja.Value
|
||||||
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
vm.Set("registerExtension", func(call goja.FunctionCall) goja.Value {
|
||||||
if len(call.Arguments) > 0 {
|
if len(call.Arguments) > 0 {
|
||||||
registeredExtension = call.Arguments[0]
|
registeredExtension = call.Arguments[0]
|
||||||
// Also set it as global 'extension' variable for later access
|
|
||||||
vm.Set("extension", call.Arguments[0])
|
vm.Set("extension", call.Arguments[0])
|
||||||
}
|
}
|
||||||
return goja.Undefined()
|
return goja.Undefined()
|
||||||
@@ -406,7 +384,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
// Check if it's an extracted extension directory
|
|
||||||
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
manifestPath := filepath.Join(dirPath, entry.Name(), "manifest.json")
|
||||||
if _, err := os.Stat(manifestPath); err == nil {
|
if _, err := os.Stat(manifestPath); err == nil {
|
||||||
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.loadExtensionFromDirectory(filepath.Join(dirPath, entry.Name()))
|
||||||
@@ -418,7 +395,6 @@ func (m *ExtensionManager) LoadExtensionsFromDirectory(dirPath string) ([]string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
} else if strings.HasSuffix(strings.ToLower(entry.Name()), ".spotiflac-ext") {
|
||||||
// Load from package file
|
|
||||||
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
ext, err := m.LoadExtensionFromFile(filepath.Join(dirPath, entry.Name()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
GoLog("[Extension] Failed to load %s: %v\n", entry.Name(), err)
|
||||||
@@ -437,7 +413,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
// Read manifest
|
|
||||||
manifestPath := filepath.Join(dirPath, "manifest.json")
|
manifestPath := filepath.Join(dirPath, "manifest.json")
|
||||||
manifestData, err := os.ReadFile(manifestPath)
|
manifestData, err := os.ReadFile(manifestPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -450,25 +425,21 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx
|
|||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if index.js exists
|
|
||||||
indexPath := filepath.Join(dirPath, "index.js")
|
indexPath := filepath.Join(dirPath, "index.js")
|
||||||
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
if _, err := os.Stat(indexPath); os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("Extension is missing index.js file")
|
return nil, fmt.Errorf("Extension is missing index.js file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension already loaded - skip silently (for directory loading on startup)
|
|
||||||
if existing, exists := m.extensions[manifest.Name]; exists {
|
if existing, exists := m.extensions[manifest.Name]; exists {
|
||||||
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
GoLog("[Extension] Extension '%s' already loaded, skipping\n", manifest.DisplayName)
|
||||||
return existing, nil
|
return existing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory for extension
|
|
||||||
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
extDataDir := filepath.Join(m.dataDir, manifest.Name)
|
||||||
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
if err := os.MkdirAll(extDataDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension data directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create loaded extension
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: manifest.Name,
|
ID: manifest.Name,
|
||||||
Manifest: manifest,
|
Manifest: manifest,
|
||||||
@@ -541,7 +512,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
var hasIndexJS bool
|
var hasIndexJS bool
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
@@ -570,13 +540,11 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
return nil, fmt.Errorf("Invalid extension package: index.js not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse and validate manifest
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
return nil, fmt.Errorf("Invalid extension manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension exists
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[newManifest.Name]
|
existing, exists := m.extensions[newManifest.Name]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
@@ -612,19 +580,15 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate extension directory
|
|
||||||
if err := os.MkdirAll(extDir, 0755); err != nil {
|
if err := os.MkdirAll(extDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
return nil, fmt.Errorf("failed to create extension directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract all files from new package (preserving directory structure)
|
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
if file.FileInfo().IsDir() {
|
if file.FileInfo().IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve relative path within the zip (support subdirectories)
|
|
||||||
// Clean the path to prevent path traversal attacks
|
|
||||||
relPath := filepath.Clean(file.Name)
|
relPath := filepath.Clean(file.Name)
|
||||||
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) {
|
||||||
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
GoLog("[Extension] Skipping unsafe path in archive: %s\n", file.Name)
|
||||||
@@ -632,19 +596,16 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
destPath := filepath.Join(extDir, relPath)
|
destPath := filepath.Join(extDir, relPath)
|
||||||
|
|
||||||
// Create parent directories if needed
|
|
||||||
destDir := filepath.Dir(destPath)
|
destDir := filepath.Dir(destPath)
|
||||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||||
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
return nil, fmt.Errorf("failed to create directory %s: %w", destDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create destination file
|
|
||||||
destFile, err := os.Create(destPath)
|
destFile, err := os.Create(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
return nil, fmt.Errorf("failed to create file %s: %w", destPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy content
|
|
||||||
srcFile, err := file.Open()
|
srcFile, err := file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
destFile.Close()
|
destFile.Close()
|
||||||
@@ -659,7 +620,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new loaded extension (reusing data directory, preserving enabled state)
|
|
||||||
ext := &LoadedExtension{
|
ext := &LoadedExtension{
|
||||||
ID: newManifest.Name,
|
ID: newManifest.Name,
|
||||||
Manifest: newManifest,
|
Manifest: newManifest,
|
||||||
@@ -708,7 +668,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
}
|
}
|
||||||
defer zipReader.Close()
|
defer zipReader.Close()
|
||||||
|
|
||||||
// Find and read manifest.json
|
|
||||||
var manifestData []byte
|
var manifestData []byte
|
||||||
for _, file := range zipReader.File {
|
for _, file := range zipReader.File {
|
||||||
name := filepath.Base(file.Name)
|
name := filepath.Base(file.Name)
|
||||||
@@ -730,13 +689,11 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
return nil, fmt.Errorf("manifest.json not found")
|
return nil, fmt.Errorf("manifest.json not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse manifest
|
|
||||||
newManifest, err := ParseManifest(manifestData)
|
newManifest, err := ParseManifest(manifestData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
return nil, fmt.Errorf("Invalid manifest: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if extension exists
|
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
existing, exists := m.extensions[newManifest.Name]
|
existing, exists := m.extensions[newManifest.Name]
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
@@ -752,7 +709,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte
|
|||||||
info.CurrentVersion = ""
|
info.CurrentVersion = ""
|
||||||
info.CanUpgrade = false
|
info.CanUpgrade = false
|
||||||
} else {
|
} else {
|
||||||
// Compare versions
|
|
||||||
info.CurrentVersion = existing.Manifest.Version
|
info.CurrentVersion = existing.Manifest.Version
|
||||||
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
info.CanUpgrade = compareVersions(newManifest.Version, existing.Manifest.Version) > 0
|
||||||
}
|
}
|
||||||
@@ -805,7 +761,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
|
|
||||||
infos := make([]ExtensionInfo, len(extensions))
|
infos := make([]ExtensionInfo, len(extensions))
|
||||||
for i, ext := range extensions {
|
for i, ext := range extensions {
|
||||||
// Build permissions list
|
|
||||||
permissions := []string{}
|
permissions := []string{}
|
||||||
for _, domain := range ext.Manifest.Permissions.Network {
|
for _, domain := range ext.Manifest.Permissions.Network {
|
||||||
permissions = append(permissions, "network:"+domain)
|
permissions = append(permissions, "network:"+domain)
|
||||||
@@ -822,7 +777,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
status = "disabled"
|
status = "disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for icon file
|
|
||||||
iconPath := ""
|
iconPath := ""
|
||||||
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
if ext.Manifest.Icon != "" && ext.SourceDir != "" {
|
||||||
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
possibleIcon := filepath.Join(ext.SourceDir, ext.Manifest.Icon)
|
||||||
@@ -830,7 +784,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) {
|
|||||||
iconPath = possibleIcon
|
iconPath = possibleIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: check for icon.png if not specified in manifest
|
|
||||||
if iconPath == "" && ext.SourceDir != "" {
|
if iconPath == "" && ext.SourceDir != "" {
|
||||||
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
possibleIcon := filepath.Join(ext.SourceDir, "icon.png")
|
||||||
if _, err := os.Stat(possibleIcon); err == nil {
|
if _, err := os.Stat(possibleIcon); err == nil {
|
||||||
@@ -887,13 +840,11 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
return fmt.Errorf("Extension failed to load. Please reinstall the extension")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert settings to JSON for passing to JS
|
|
||||||
settingsJSON, err := json.Marshal(settings)
|
settingsJSON, err := json.Marshal(settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("Failed to save settings")
|
return fmt.Errorf("Failed to save settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call initialize function
|
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
(function() {
|
(function() {
|
||||||
var settings = %s;
|
var settings = %s;
|
||||||
@@ -917,7 +868,6 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check result
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
exported := result.Export()
|
exported := result.Export()
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
@@ -973,7 +923,6 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check result
|
|
||||||
if result != nil && !goja.IsUndefined(result) {
|
if result != nil && !goja.IsUndefined(result) {
|
||||||
exported := result.Export()
|
exported := result.Export()
|
||||||
if resultMap, ok := exported.(map[string]interface{}); ok {
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
@@ -1010,3 +959,60 @@ func (m *ExtensionManager) UnloadAllExtensions() {
|
|||||||
|
|
||||||
GoLog("[Extension] All extensions unloaded\n")
|
GoLog("[Extension] All extensions unloaded\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InvokeAction calls a custom action function on an extension (e.g., for button settings)
|
||||||
|
// The function is called as extension.<actionName>() and can return a result
|
||||||
|
func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) (map[string]interface{}, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
ext, exists := m.extensions[extensionID]
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("extension not found: %s", extensionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ext.VM == nil {
|
||||||
|
return nil, fmt.Errorf("extension VM not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ext.Enabled {
|
||||||
|
return nil, fmt.Errorf("extension is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the action function on the extension object
|
||||||
|
script := fmt.Sprintf(`
|
||||||
|
(function() {
|
||||||
|
if (typeof extension !== 'undefined' && typeof extension.%s === 'function') {
|
||||||
|
try {
|
||||||
|
var result = extension.%s();
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
// Handle promise - return pending status
|
||||||
|
return { success: true, pending: true, message: 'Action started' };
|
||||||
|
}
|
||||||
|
return { success: true, result: result };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: e.toString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Action function not found: %s' };
|
||||||
|
})()
|
||||||
|
`, actionName, actionName, actionName)
|
||||||
|
|
||||||
|
result, err := RunWithTimeoutAndRecover(ext.VM, script, DefaultJSTimeout)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Extension] InvokeAction error for %s.%s: %v\n", extensionID, actionName, err)
|
||||||
|
return nil, fmt.Errorf("action failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil || goja.IsUndefined(result) {
|
||||||
|
return map[string]interface{}{"success": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
exported := result.Export()
|
||||||
|
if resultMap, ok := exported.(map[string]interface{}); ok {
|
||||||
|
GoLog("[Extension] InvokeAction %s.%s result: %v\n", extensionID, actionName, resultMap)
|
||||||
|
return resultMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{"success": true, "result": exported}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
SettingTypeNumber SettingType = "number"
|
SettingTypeNumber SettingType = "number"
|
||||||
SettingTypeBool SettingType = "boolean"
|
SettingTypeBool SettingType = "boolean"
|
||||||
SettingTypeSelect SettingType = "select"
|
SettingTypeSelect SettingType = "select"
|
||||||
|
SettingTypeButton SettingType = "button" // Action button that calls a JS function
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtensionPermissions defines what resources an extension can access
|
// ExtensionPermissions defines what resources an extension can access
|
||||||
@@ -42,6 +43,7 @@ type ExtensionSetting struct {
|
|||||||
Secret bool `json:"secret,omitempty"`
|
Secret bool `json:"secret,omitempty"`
|
||||||
Default interface{} `json:"default,omitempty"`
|
Default interface{} `json:"default,omitempty"`
|
||||||
Options []string `json:"options,omitempty"` // For select type
|
Options []string `json:"options,omitempty"` // For select type
|
||||||
|
Action string `json:"action,omitempty"` // For button type: JS function name to call (e.g., "startLogin")
|
||||||
}
|
}
|
||||||
|
|
||||||
// QualityOption represents a quality option for download providers
|
// QualityOption represents a quality option for download providers
|
||||||
@@ -204,6 +206,7 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
SettingTypeNumber: true,
|
SettingTypeNumber: true,
|
||||||
SettingTypeBool: true,
|
SettingTypeBool: true,
|
||||||
SettingTypeSelect: true,
|
SettingTypeSelect: true,
|
||||||
|
SettingTypeButton: true,
|
||||||
}
|
}
|
||||||
if !validTypes[setting.Type] {
|
if !validTypes[setting.Type] {
|
||||||
return &ManifestValidationError{
|
return &ManifestValidationError{
|
||||||
@@ -219,6 +222,14 @@ func (m *ExtensionManifest) Validate() error {
|
|||||||
Message: "select type requires options",
|
Message: "select type requires options",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Button type requires action
|
||||||
|
if setting.Type == SettingTypeButton && setting.Action == "" {
|
||||||
|
return &ManifestValidationError{
|
||||||
|
Field: fmt.Sprintf("settings[%d].action", i),
|
||||||
|
Message: "button type requires action (JS function name)",
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -29,6 +30,14 @@ type ExtTrackMetadata struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
ISRC string `json:"isrc,omitempty"`
|
ISRC string `json:"isrc,omitempty"`
|
||||||
ProviderID string `json:"provider_id"`
|
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
|
// ResolvedCoverURL returns the cover URL, checking both CoverURL and Images fields
|
||||||
@@ -54,11 +63,14 @@ type ExtAlbumMetadata struct {
|
|||||||
|
|
||||||
// ExtArtistMetadata represents artist metadata from an extension
|
// ExtArtistMetadata represents artist metadata from an extension
|
||||||
type ExtArtistMetadata struct {
|
type ExtArtistMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ImageURL string `json:"image_url,omitempty"`
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
HeaderImage string `json:"header_image,omitempty"` // Header image for artist page background
|
||||||
ProviderID string `json:"provider_id"`
|
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
|
// ExtSearchResult represents search results from an extension
|
||||||
@@ -177,7 +189,6 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider ID on all tracks
|
|
||||||
for i := range searchResult.Tracks {
|
for i := range searchResult.Tracks {
|
||||||
searchResult.Tracks[i].ProviderID = p.extension.ID
|
searchResult.Tracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -725,12 +736,22 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
enrichedTrack, err := provider.EnrichTrack(trackMeta)
|
||||||
if err == nil && enrichedTrack != nil {
|
if err == nil && enrichedTrack != nil {
|
||||||
// Update request with enriched data
|
|
||||||
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
if enrichedTrack.ISRC != "" && enrichedTrack.ISRC != req.ISRC {
|
||||||
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
GoLog("[DownloadWithExtensionFallback] ISRC enriched: %s -> %s\n", req.ISRC, enrichedTrack.ISRC)
|
||||||
req.ISRC = enrichedTrack.ISRC
|
req.ISRC = enrichedTrack.ISRC
|
||||||
}
|
}
|
||||||
// Can also update other fields if needed
|
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
|
||||||
|
}
|
||||||
if enrichedTrack.Name != "" {
|
if enrichedTrack.Name != "" {
|
||||||
req.TrackName = enrichedTrack.Name
|
req.TrackName = enrichedTrack.Name
|
||||||
}
|
}
|
||||||
@@ -747,7 +768,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
ext, err := extManager.GetExtension(req.Source)
|
ext, err := extManager.GetExtension(req.Source)
|
||||||
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
if err == nil && ext.Enabled && ext.Error == "" && ext.Manifest.IsDownloadProvider() {
|
||||||
// Check if this extension wants to skip built-in fallback
|
|
||||||
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
skipBuiltIn = ext.Manifest.SkipBuiltInFallback
|
||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
@@ -758,7 +778,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn)
|
||||||
|
|
||||||
// Build output path
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPath(req)
|
||||||
|
|
||||||
// Download directly using the track ID from the extension
|
// Download directly using the track ID from the extension
|
||||||
@@ -778,6 +797,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Service: req.Source,
|
Service: req.Source,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Embed genre and label if provided (from Deezer metadata)
|
||||||
|
if req.Genre != "" || req.Label != "" {
|
||||||
|
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If extension has skipMetadataEnrichment, copy metadata
|
// If extension has skipMetadataEnrichment, copy metadata
|
||||||
if ext.Manifest.SkipMetadataEnrichment {
|
if ext.Manifest.SkipMetadataEnrichment {
|
||||||
resp.SkipMetadataEnrichment = true
|
resp.SkipMetadataEnrichment = true
|
||||||
@@ -814,6 +842,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download cancelled",
|
||||||
|
ErrorType: "cancelled",
|
||||||
|
Service: req.Source,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else if result.ErrorMessage != "" {
|
} else if result.ErrorMessage != "" {
|
||||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||||
@@ -858,6 +894,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download cancelled",
|
||||||
|
ErrorType: "cancelled",
|
||||||
|
Service: providerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err)
|
||||||
}
|
}
|
||||||
@@ -875,7 +919,6 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
|
|
||||||
provider := NewExtensionProviderWrapper(ext)
|
provider := NewExtensionProviderWrapper(ext)
|
||||||
|
|
||||||
// Check availability first
|
|
||||||
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName)
|
||||||
if err != nil || !availability.Available {
|
if err != nil || !availability.Available {
|
||||||
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID)
|
||||||
@@ -885,12 +928,9 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build output path
|
|
||||||
outputPath := buildOutputPath(req)
|
outputPath := buildOutputPath(req)
|
||||||
|
|
||||||
// Download
|
|
||||||
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) {
|
||||||
// Update progress
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
SetItemProgress(req.ItemID, float64(percent), 0, 0)
|
||||||
}
|
}
|
||||||
@@ -906,6 +946,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
Service: providerID,
|
Service: providerID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Embed genre and label if provided (from Deezer metadata)
|
||||||
|
if req.Genre != "" || req.Label != "" {
|
||||||
|
if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err)
|
||||||
|
} else {
|
||||||
|
GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If extension has skipMetadataEnrichment and returned metadata, use it
|
// If extension has skipMetadataEnrichment and returned metadata, use it
|
||||||
if ext.Manifest.SkipMetadataEnrichment {
|
if ext.Manifest.SkipMetadataEnrichment {
|
||||||
resp.SkipMetadataEnrichment = true
|
resp.SkipMetadataEnrichment = true
|
||||||
@@ -943,6 +992,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return &DownloadResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Download cancelled",
|
||||||
|
ErrorType: "cancelled",
|
||||||
|
Service: providerID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
} else if result.ErrorMessage != "" {
|
} else if result.ErrorMessage != "" {
|
||||||
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
lastErr = fmt.Errorf("%s", result.ErrorMessage)
|
||||||
@@ -1122,7 +1179,6 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
|
|||||||
tracks = []ExtTrackMetadata{}
|
tracks = []ExtTrackMetadata{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set provider ID on all tracks
|
|
||||||
for i := range tracks {
|
for i := range tracks {
|
||||||
tracks[i].ProviderID = p.extension.ID
|
tracks[i].ProviderID = p.extension.ID
|
||||||
}
|
}
|
||||||
@@ -1192,6 +1248,24 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||||||
for i := range handleResult.Tracks {
|
for i := range handleResult.Tracks {
|
||||||
handleResult.Tracks[i].ProviderID = p.extension.ID
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range handleResult.Artist.TopTracks {
|
||||||
|
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &handleResult, nil
|
return &handleResult, nil
|
||||||
}
|
}
|
||||||
@@ -1425,12 +1499,10 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin
|
|||||||
for _, provider := range providers {
|
for _, provider := range providers {
|
||||||
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
hooks := provider.extension.Manifest.GetPostProcessingHooks()
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
// Check if hook is enabled (TODO: check user settings)
|
|
||||||
if !hook.DefaultEnabled {
|
if !hook.DefaultEnabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if format is supported
|
|
||||||
ext := strings.ToLower(filepath.Ext(currentPath))
|
ext := strings.ToLower(filepath.Ext(currentPath))
|
||||||
if len(hook.SupportedFormats) > 0 {
|
if len(hook.SupportedFormats) > 0 {
|
||||||
supported := false
|
supported := false
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ import (
|
|||||||
"github.com/dop251/goja"
|
"github.com/dop251/goja"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Default timeout for JS execution (30 seconds)
|
|
||||||
const DefaultJSTimeout = 30 * time.Second
|
const DefaultJSTimeout = 30 * time.Second
|
||||||
|
|
||||||
// Global auth state for extensions (stores pending auth codes)
|
|
||||||
var (
|
var (
|
||||||
extensionAuthState = make(map[string]*ExtensionAuthState)
|
extensionAuthState = make(map[string]*ExtensionAuthState)
|
||||||
extensionAuthStateMu sync.RWMutex
|
extensionAuthStateMu sync.RWMutex
|
||||||
@@ -39,7 +37,6 @@ type PendingAuthRequest struct {
|
|||||||
CallbackURL string
|
CallbackURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global pending auth requests (Flutter polls this)
|
|
||||||
var (
|
var (
|
||||||
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
pendingAuthRequests = make(map[string]*PendingAuthRequest)
|
||||||
pendingAuthRequestsMu sync.RWMutex
|
pendingAuthRequestsMu sync.RWMutex
|
||||||
@@ -52,7 +49,6 @@ func GetPendingAuthRequest(extensionID string) *PendingAuthRequest {
|
|||||||
return pendingAuthRequests[extensionID]
|
return pendingAuthRequests[extensionID]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearPendingAuthRequest clears pending auth request (called from Flutter after opening URL)
|
|
||||||
func ClearPendingAuthRequest(extensionID string) {
|
func ClearPendingAuthRequest(extensionID string) {
|
||||||
pendingAuthRequestsMu.Lock()
|
pendingAuthRequestsMu.Lock()
|
||||||
defer pendingAuthRequestsMu.Unlock()
|
defer pendingAuthRequestsMu.Unlock()
|
||||||
@@ -101,7 +97,6 @@ type ExtensionRuntime struct {
|
|||||||
|
|
||||||
// NewExtensionRuntime creates a new runtime for an extension
|
// NewExtensionRuntime creates a new runtime for an extension
|
||||||
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime {
|
||||||
// Create a cookie jar for this extension
|
|
||||||
jar, _ := newSimpleCookieJar()
|
jar, _ := newSimpleCookieJar()
|
||||||
|
|
||||||
runtime := &ExtensionRuntime{
|
runtime := &ExtensionRuntime{
|
||||||
|
|||||||
@@ -11,23 +11,18 @@ var invalidChars = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
|
|||||||
|
|
||||||
// sanitizeFilename removes invalid characters from filename
|
// sanitizeFilename removes invalid characters from filename
|
||||||
func sanitizeFilename(filename string) string {
|
func sanitizeFilename(filename string) string {
|
||||||
// Replace invalid characters with underscore
|
|
||||||
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
sanitized := invalidChars.ReplaceAllString(filename, "_")
|
||||||
|
|
||||||
// Remove leading/trailing spaces and dots
|
|
||||||
sanitized = strings.TrimSpace(sanitized)
|
sanitized = strings.TrimSpace(sanitized)
|
||||||
sanitized = strings.Trim(sanitized, ".")
|
sanitized = strings.Trim(sanitized, ".")
|
||||||
|
|
||||||
// Collapse multiple underscores
|
|
||||||
multiUnderscore := regexp.MustCompile(`_+`)
|
multiUnderscore := regexp.MustCompile(`_+`)
|
||||||
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
sanitized = multiUnderscore.ReplaceAllString(sanitized, "_")
|
||||||
|
|
||||||
// Limit length (Android has 255 byte limit for filenames)
|
|
||||||
if len(sanitized) > 200 {
|
if len(sanitized) > 200 {
|
||||||
sanitized = sanitized[:200]
|
sanitized = sanitized[:200]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure not empty
|
|
||||||
if sanitized == "" {
|
if sanitized == "" {
|
||||||
sanitized = "untitled"
|
sanitized = "untitled"
|
||||||
}
|
}
|
||||||
@@ -43,7 +38,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
|
|
||||||
result := template
|
result := template
|
||||||
|
|
||||||
// Replace placeholders
|
|
||||||
placeholders := map[string]string{
|
placeholders := map[string]string{
|
||||||
"{title}": getString(metadata, "title"),
|
"{title}": getString(metadata, "title"),
|
||||||
"{artist}": getString(metadata, "artist"),
|
"{artist}": getString(metadata, "artist"),
|
||||||
@@ -63,7 +57,6 @@ func buildFilenameFromTemplate(template string, metadata map[string]interface{})
|
|||||||
func getString(m map[string]interface{}, key string) string {
|
func getString(m map[string]interface{}, key string) string {
|
||||||
if v, ok := m[key]; ok {
|
if v, ok := m[key]; ok {
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
// Trim leading/trailing whitespace to prevent filename issues
|
|
||||||
return strings.TrimSpace(s)
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,13 +20,11 @@ import (
|
|||||||
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
// getRandomUserAgent generates a random Windows Chrome User-Agent string
|
||||||
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
// Uses same format as PC version (referensi/backend/spotify_metadata.go) for better API compatibility
|
||||||
func getRandomUserAgent() string {
|
func getRandomUserAgent() string {
|
||||||
// Windows 10/11 Chrome format - same as PC version for maximum compatibility
|
winMajor := rand.Intn(2) + 10
|
||||||
// Some APIs may block mobile User-Agents, so we use desktop format
|
|
||||||
winMajor := rand.Intn(2) + 10 // Windows 10 or 11
|
|
||||||
|
|
||||||
chromeVersion := rand.Intn(25) + 100 // Chrome 100-124
|
chromeVersion := rand.Intn(25) + 100
|
||||||
chromeBuild := rand.Intn(1500) + 3000 // Build 3000-4500
|
chromeBuild := rand.Intn(1500) + 3000
|
||||||
chromePatch := rand.Intn(65) + 60 // Patch 60-125
|
chromePatch := rand.Intn(65) + 60
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
"Mozilla/5.0 (Windows NT %d.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/%d.0.%d.%d Safari/537.36",
|
||||||
@@ -39,7 +37,6 @@ func getRandomUserAgent() string {
|
|||||||
|
|
||||||
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
// getRandomMacUserAgent generates a random Mac Chrome User-Agent string
|
||||||
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
// Alternative format matching referensi/backend/spotify_metadata.go exactly
|
||||||
// Kept for potential future use
|
|
||||||
// func getRandomMacUserAgent() string {
|
// func getRandomMacUserAgent() string {
|
||||||
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
// macMajor := rand.Intn(4) + 11 // macOS 11-14
|
||||||
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
// macMinor := rand.Intn(5) + 4 // Minor 4-8
|
||||||
@@ -66,7 +63,6 @@ func getRandomUserAgent() string {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
// getRandomDesktopUserAgent randomly picks between Windows and Mac User-Agent
|
||||||
// Kept for potential future use
|
|
||||||
// func getRandomDesktopUserAgent() string {
|
// func getRandomDesktopUserAgent() string {
|
||||||
// if rand.Intn(2) == 0 {
|
// if rand.Intn(2) == 0 {
|
||||||
// return getRandomUserAgent() // Windows
|
// return getRandomUserAgent() // Windows
|
||||||
@@ -74,17 +70,15 @@ func getRandomUserAgent() string {
|
|||||||
// return getRandomMacUserAgent() // Mac
|
// return getRandomMacUserAgent() // Mac
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// Default timeout values
|
|
||||||
const (
|
const (
|
||||||
DefaultTimeout = 60 * time.Second // Default HTTP timeout
|
DefaultTimeout = 60 * time.Second
|
||||||
DownloadTimeout = 120 * time.Second // Timeout for file downloads
|
DownloadTimeout = 120 * time.Second
|
||||||
SongLinkTimeout = 30 * time.Second // Timeout for SongLink API
|
SongLinkTimeout = 30 * time.Second
|
||||||
DefaultMaxRetries = 3 // Default retry count
|
DefaultMaxRetries = 3
|
||||||
DefaultRetryDelay = 1 * time.Second // Initial retry delay
|
DefaultRetryDelay = 1 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Shared transport with connection pooling to prevent TCP exhaustion
|
// Shared transport with connection pooling to prevent TCP exhaustion
|
||||||
// Optimized for large file downloads (FLAC ~30-50MB)
|
|
||||||
var sharedTransport = &http.Transport{
|
var sharedTransport = &http.Transport{
|
||||||
DialContext: (&net.Dialer{
|
DialContext: (&net.Dialer{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
@@ -96,27 +90,24 @@ var sharedTransport = &http.Transport{
|
|||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
TLSHandshakeTimeout: 10 * time.Second,
|
TLSHandshakeTimeout: 10 * time.Second,
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
DisableKeepAlives: false, // Enable keep-alives for connection reuse
|
DisableKeepAlives: false,
|
||||||
ForceAttemptHTTP2: true,
|
ForceAttemptHTTP2: true,
|
||||||
WriteBufferSize: 64 * 1024, // 64KB write buffer
|
WriteBufferSize: 64 * 1024,
|
||||||
ReadBufferSize: 64 * 1024, // 64KB read buffer
|
ReadBufferSize: 64 * 1024,
|
||||||
DisableCompression: true, // FLAC is already compressed
|
DisableCompression: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for general requests (reuses connections)
|
|
||||||
var sharedClient = &http.Client{
|
var sharedClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DefaultTimeout,
|
Timeout: DefaultTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared HTTP client for downloads (longer timeout, reuses connections)
|
|
||||||
var downloadClient = &http.Client{
|
var downloadClient = &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
Timeout: DownloadTimeout,
|
Timeout: DownloadTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
// NewHTTPClientWithTimeout creates an HTTP client with specified timeout
|
||||||
// Uses shared transport for connection reuse
|
|
||||||
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Transport: sharedTransport,
|
Transport: sharedTransport,
|
||||||
@@ -124,18 +115,15 @@ func NewHTTPClientWithTimeout(timeout time.Duration) *http.Client {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSharedClient returns the shared HTTP client for general requests
|
|
||||||
func GetSharedClient() *http.Client {
|
func GetSharedClient() *http.Client {
|
||||||
return sharedClient
|
return sharedClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDownloadClient returns the shared HTTP client for downloads
|
|
||||||
func GetDownloadClient() *http.Client {
|
func GetDownloadClient() *http.Client {
|
||||||
return downloadClient
|
return downloadClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseIdleConnections closes idle connections in the shared transport
|
// CloseIdleConnections closes idle connections in the shared transport
|
||||||
// Call this periodically during large batch downloads to prevent connection buildup
|
|
||||||
func CloseIdleConnections() {
|
func CloseIdleConnections() {
|
||||||
sharedTransport.CloseIdleConnections()
|
sharedTransport.CloseIdleConnections()
|
||||||
}
|
}
|
||||||
@@ -146,7 +134,6 @@ func DoRequestWithUserAgent(client *http.Client, req *http.Request) (*http.Respo
|
|||||||
req.Header.Set("User-Agent", getRandomUserAgent())
|
req.Header.Set("User-Agent", getRandomUserAgent())
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Check for ISP blocking
|
|
||||||
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
CheckAndLogISPBlocking(err, req.URL.String(), "HTTP")
|
||||||
}
|
}
|
||||||
return resp, err
|
return resp, err
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ type LogBuffer struct {
|
|||||||
entries []LogEntry
|
entries []LogEntry
|
||||||
maxSize int
|
maxSize int
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
loggingEnabled bool // Whether logging is enabled (controlled by Flutter)
|
loggingEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -60,7 +60,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
lb.mu.Lock()
|
lb.mu.Lock()
|
||||||
defer lb.mu.Unlock()
|
defer lb.mu.Unlock()
|
||||||
|
|
||||||
// Skip if logging is disabled (except for errors which are always logged)
|
|
||||||
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
if !lb.loggingEnabled && level != "ERROR" && level != "FATAL" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -73,12 +72,10 @@ func (lb *LogBuffer) Add(level, tag, message string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(lb.entries) >= lb.maxSize {
|
if len(lb.entries) >= lb.maxSize {
|
||||||
// Remove oldest entry
|
|
||||||
lb.entries = lb.entries[1:]
|
lb.entries = lb.entries[1:]
|
||||||
}
|
}
|
||||||
lb.entries = append(lb.entries, entry)
|
lb.entries = append(lb.entries, entry)
|
||||||
|
|
||||||
// Also print to logcat for debugging
|
|
||||||
fmt.Printf("[%s] %s\n", tag, message)
|
fmt.Printf("[%s] %s\n", tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +88,6 @@ func (lb *LogBuffer) GetAll() string {
|
|||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSince returns log entries since the given index (internal use)
|
|
||||||
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
func (lb *LogBuffer) getSince(index int) ([]LogEntry, int) {
|
||||||
lb.mu.RLock()
|
lb.mu.RLock()
|
||||||
defer lb.mu.RUnlock()
|
defer lb.mu.RUnlock()
|
||||||
|
|||||||
@@ -3,14 +3,100 @@ package gobackend
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Lyrics Cache with TTL
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
lyricsCacheTTL = 24 * time.Hour // Cache lyrics for 24 hours
|
||||||
|
durationToleranceSec = 10.0 // Duration matching tolerance in seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
type lyricsCacheEntry struct {
|
||||||
|
response *LyricsResponse
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type lyricsCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]*lyricsCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
var globalLyricsCache = &lyricsCache{
|
||||||
|
cache: make(map[string]*lyricsCacheEntry),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) generateKey(artist, track string, durationSec float64) string {
|
||||||
|
// Normalize key: lowercase, trim spaces
|
||||||
|
normalizedArtist := strings.ToLower(strings.TrimSpace(artist))
|
||||||
|
normalizedTrack := strings.ToLower(strings.TrimSpace(track))
|
||||||
|
// Round duration to nearest 10 seconds for cache key
|
||||||
|
roundedDuration := math.Round(durationSec/10) * 10
|
||||||
|
return fmt.Sprintf("%s|%s|%.0f", normalizedArtist, normalizedTrack, roundedDuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Get(artist, track string, durationSec float64) (*LyricsResponse, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
key := c.generateKey(artist, track, durationSec)
|
||||||
|
entry, exists := c.cache[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if expired
|
||||||
|
if time.Now().After(entry.expiresAt) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.response, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *lyricsCache) Set(artist, track string, durationSec float64, response *LyricsResponse) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
key := c.generateKey(artist, track, durationSec)
|
||||||
|
c.cache[key] = &lyricsCacheEntry{
|
||||||
|
response: response,
|
||||||
|
expiresAt: time.Now().Add(lyricsCacheTTL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanExpired removes expired entries from cache
|
||||||
|
func (c *lyricsCache) CleanExpired() int {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cleaned := 0
|
||||||
|
for key, entry := range c.cache {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(c.cache, key)
|
||||||
|
cleaned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size returns current cache size
|
||||||
|
func (c *lyricsCache) Size() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.cache)
|
||||||
|
}
|
||||||
|
|
||||||
type LRCLibResponse struct {
|
type LRCLibResponse struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -86,7 +172,9 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes
|
|||||||
return c.parseLRCLibResponse(&lrcResp), nil
|
return c.parseLRCLibResponse(&lrcResp), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsResponse, error) {
|
// FetchLyricsFromLRCLibSearch searches lyrics with optional duration matching
|
||||||
|
// durationSec: track duration in seconds, use 0 to skip duration matching
|
||||||
|
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec float64) (*LyricsResponse, error) {
|
||||||
baseURL := "https://lrclib.net/api/search"
|
baseURL := "https://lrclib.net/api/search"
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("q", query)
|
params.Set("q", query)
|
||||||
@@ -118,6 +206,13 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
|||||||
return nil, fmt.Errorf("no lyrics found")
|
return nil, fmt.Errorf("no lyrics found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter and score results based on duration matching and synced lyrics
|
||||||
|
bestMatch := c.findBestMatch(results, durationSec)
|
||||||
|
if bestMatch != nil {
|
||||||
|
return c.parseLRCLibResponse(bestMatch), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: return first result with synced lyrics
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
if result.SyncedLyrics != "" {
|
if result.SyncedLyrics != "" {
|
||||||
return c.parseLRCLibResponse(&result), nil
|
return c.parseLRCLibResponse(&result), nil
|
||||||
@@ -127,38 +222,89 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string) (*LyricsRespons
|
|||||||
return c.parseLRCLibResponse(&results[0]), nil
|
return c.parseLRCLibResponse(&results[0]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string) (*LyricsResponse, error) {
|
// findBestMatch finds the best matching lyrics based on duration and sync status
|
||||||
// Strategy 1: Direct match with artist and track name
|
func (c *LyricsClient) findBestMatch(results []LRCLibResponse, targetDurationSec float64) *LRCLibResponse {
|
||||||
lyrics, err := c.FetchLyricsWithMetadata(artistName, trackName)
|
var bestSynced *LRCLibResponse
|
||||||
|
var bestPlain *LRCLibResponse
|
||||||
|
|
||||||
|
for i := range results {
|
||||||
|
result := &results[i]
|
||||||
|
|
||||||
|
// Check duration match if target duration is provided
|
||||||
|
durationMatches := targetDurationSec == 0 || c.durationMatches(result.Duration, targetDurationSec)
|
||||||
|
|
||||||
|
if durationMatches {
|
||||||
|
// Prefer synced lyrics over plain
|
||||||
|
if result.SyncedLyrics != "" && bestSynced == nil {
|
||||||
|
bestSynced = result
|
||||||
|
} else if result.PlainLyrics != "" && bestPlain == nil {
|
||||||
|
bestPlain = result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return synced first, then plain
|
||||||
|
if bestSynced != nil {
|
||||||
|
return bestSynced
|
||||||
|
}
|
||||||
|
return bestPlain
|
||||||
|
}
|
||||||
|
|
||||||
|
// durationMatches checks if two durations are within tolerance
|
||||||
|
func (c *LyricsClient) durationMatches(lrcDuration, targetDuration float64) bool {
|
||||||
|
diff := math.Abs(lrcDuration - targetDuration)
|
||||||
|
return diff <= durationToleranceSec
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchLyricsAllSources fetches lyrics from multiple sources with caching and duration matching
|
||||||
|
// durationSec: track duration in seconds for matching, use 0 to skip duration matching
|
||||||
|
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, durationSec float64) (*LyricsResponse, error) {
|
||||||
|
// Check cache first
|
||||||
|
if cached, found := globalLyricsCache.Get(artistName, trackName, durationSec); found {
|
||||||
|
fmt.Printf("[Lyrics] Cache hit for: %s - %s\n", artistName, trackName)
|
||||||
|
cachedCopy := *cached
|
||||||
|
cachedCopy.Source = cached.Source + " (cached)"
|
||||||
|
return &cachedCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lyrics *LyricsResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Try exact match first
|
||||||
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
lyrics.Source = "LRCLIB"
|
lyrics.Source = "LRCLIB"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Try with simplified track name
|
// Try with simplified track name
|
||||||
simplifiedTrack := simplifyTrackName(trackName)
|
simplifiedTrack := simplifyTrackName(trackName)
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
lyrics, err = c.FetchLyricsWithMetadata(artistName, simplifiedTrack)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
lyrics.Source = "LRCLIB (simplified)"
|
lyrics.Source = "LRCLIB (simplified)"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Search with full query
|
// Search with duration matching
|
||||||
query := artistName + " " + trackName
|
query := artistName + " " + trackName
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
lyrics.Source = "LRCLIB Search"
|
lyrics.Source = "LRCLIB Search"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: Search with simplified query
|
// Search with simplified name and duration matching
|
||||||
if simplifiedTrack != trackName {
|
if simplifiedTrack != trackName {
|
||||||
query = artistName + " " + simplifiedTrack
|
query = artistName + " " + simplifiedTrack
|
||||||
lyrics, err = c.FetchLyricsFromLRCLibSearch(query)
|
lyrics, err = c.FetchLyricsFromLRCLibSearch(query, durationSec)
|
||||||
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
if err == nil && lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
lyrics.Source = "LRCLIB Search (simplified)"
|
lyrics.Source = "LRCLIB Search (simplified)"
|
||||||
|
globalLyricsCache.Set(artistName, trackName, durationSec, lyrics)
|
||||||
return lyrics, nil
|
return lyrics, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ type Metadata struct {
|
|||||||
ISRC string
|
ISRC string
|
||||||
Description string
|
Description string
|
||||||
Lyrics string
|
Lyrics string
|
||||||
|
Genre string // Music genre (e.g., "Rock", "Pop", "Electronic")
|
||||||
|
Label string // Record label (ORGANIZATION tag in Vorbis)
|
||||||
|
Copyright string // Copyright information
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedMetadata embeds metadata into a FLAC file
|
// EmbedMetadata embeds metadata into a FLAC file
|
||||||
@@ -33,7 +36,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create vorbis comment block
|
|
||||||
var cmtIdx int = -1
|
var cmtIdx int = -1
|
||||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
@@ -52,7 +54,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
cmt = flacvorbis.New()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata fields
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setComment(cmt, "ARTIST", metadata.Artist)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
@@ -84,7 +85,18 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or add vorbis comment block
|
if metadata.Genre != "" {
|
||||||
|
setComment(cmt, "GENRE", metadata.Genre)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -92,14 +104,12 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
f.Meta = append(f.Meta, &cmtBlock)
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cover art if provided
|
|
||||||
if coverPath != "" {
|
if coverPath != "" {
|
||||||
if fileExists(coverPath) {
|
if fileExists(coverPath) {
|
||||||
coverData, err := os.ReadFile(coverPath)
|
coverData, err := os.ReadFile(coverPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
fmt.Printf("[Metadata] Warning: Failed to read cover file %s: %v\n", coverPath, err)
|
||||||
} else {
|
} else {
|
||||||
// Remove existing picture blocks first (like PC version)
|
|
||||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||||
if f.Meta[i].Type == flac.Picture {
|
if f.Meta[i].Type == flac.Picture {
|
||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
@@ -125,7 +135,6 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +146,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find or create vorbis comment block
|
|
||||||
var cmtIdx int = -1
|
var cmtIdx int = -1
|
||||||
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
@@ -156,7 +164,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
cmt = flacvorbis.New()
|
cmt = flacvorbis.New()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set metadata fields
|
|
||||||
setComment(cmt, "TITLE", metadata.Title)
|
setComment(cmt, "TITLE", metadata.Title)
|
||||||
setComment(cmt, "ARTIST", metadata.Artist)
|
setComment(cmt, "ARTIST", metadata.Artist)
|
||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
@@ -188,7 +195,18 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
setComment(cmt, "UNSYNCEDLYRICS", metadata.Lyrics)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update or add vorbis comment block
|
if metadata.Genre != "" {
|
||||||
|
setComment(cmt, "GENRE", metadata.Genre)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", metadata.Label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Copyright != "" {
|
||||||
|
setComment(cmt, "COPYRIGHT", metadata.Copyright)
|
||||||
|
}
|
||||||
|
|
||||||
cmtBlock := cmt.Marshal()
|
cmtBlock := cmt.Marshal()
|
||||||
if cmtIdx >= 0 {
|
if cmtIdx >= 0 {
|
||||||
f.Meta[cmtIdx] = &cmtBlock
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
@@ -196,9 +214,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
f.Meta = append(f.Meta, &cmtBlock)
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add cover art if provided
|
|
||||||
if len(coverData) > 0 {
|
if len(coverData) > 0 {
|
||||||
// Remove existing picture blocks first
|
|
||||||
for i := len(f.Meta) - 1; i >= 0; i-- {
|
for i := len(f.Meta) - 1; i >= 0; i-- {
|
||||||
if f.Meta[i].Type == flac.Picture {
|
if f.Meta[i].Type == flac.Picture {
|
||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
@@ -220,7 +236,6 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save file
|
|
||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +272,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
fmt.Sscanf(trackNum, "%d", &metadata.TrackNumber)
|
||||||
}
|
}
|
||||||
// Also try lowercase variant (some encoders use lowercase)
|
|
||||||
if metadata.TrackNumber == 0 {
|
if metadata.TrackNumber == 0 {
|
||||||
trackNum = getComment(cmt, "TRACK")
|
trackNum = getComment(cmt, "TRACK")
|
||||||
if trackNum != "" {
|
if trackNum != "" {
|
||||||
@@ -269,7 +283,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
// Also try DISC variant
|
|
||||||
if metadata.DiscNumber == 0 {
|
if metadata.DiscNumber == 0 {
|
||||||
discNum = getComment(cmt, "DISC")
|
discNum = getComment(cmt, "DISC")
|
||||||
if discNum != "" {
|
if discNum != "" {
|
||||||
@@ -277,7 +290,6 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try DATE variants
|
|
||||||
if metadata.Date == "" {
|
if metadata.Date == "" {
|
||||||
metadata.Date = getComment(cmt, "YEAR")
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
}
|
}
|
||||||
@@ -293,7 +305,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Remove existing (case-insensitive comparison for Vorbis comments)
|
|
||||||
keyUpper := strings.ToUpper(key)
|
keyUpper := strings.ToUpper(key)
|
||||||
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
for i := len(cmt.Comments) - 1; i >= 0; i-- {
|
||||||
comment := cmt.Comments[i]
|
comment := cmt.Comments[i]
|
||||||
@@ -305,7 +316,6 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add new
|
|
||||||
cmt.Comments = append(cmt.Comments, key+"="+value)
|
cmt.Comments = append(cmt.Comments, key+"="+value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +323,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|||||||
keyUpper := strings.ToUpper(key) + "="
|
keyUpper := strings.ToUpper(key) + "="
|
||||||
for _, comment := range cmt.Comments {
|
for _, comment := range cmt.Comments {
|
||||||
if len(comment) > len(key) {
|
if len(comment) > len(key) {
|
||||||
// Case-insensitive comparison for Vorbis comments
|
|
||||||
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
commentUpper := strings.ToUpper(comment[:len(key)+1])
|
||||||
if commentUpper == keyUpper {
|
if commentUpper == keyUpper {
|
||||||
return comment[len(key)+1:]
|
return comment[len(key)+1:]
|
||||||
@@ -323,7 +332,6 @@ func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// fileExists checks if a file exists
|
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
return err == nil
|
||||||
@@ -367,6 +375,53 @@ func EmbedLyrics(filePath string, lyrics string) error {
|
|||||||
return f.Save(filePath)
|
return f.Save(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation
|
||||||
|
// This is used for extension downloads where the file is already downloaded
|
||||||
|
func EmbedGenreLabel(filePath string, genre, label string) error {
|
||||||
|
if genre == "" && label == "" {
|
||||||
|
return nil // Nothing to embed
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := flac.ParseFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse FLAC file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmtIdx int = -1
|
||||||
|
var cmt *flacvorbis.MetaDataBlockVorbisComment
|
||||||
|
|
||||||
|
for idx, meta := range f.Meta {
|
||||||
|
if meta.Type == flac.VorbisComment {
|
||||||
|
cmtIdx = idx
|
||||||
|
cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse vorbis comment: %w", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmt == nil {
|
||||||
|
cmt = flacvorbis.New()
|
||||||
|
}
|
||||||
|
|
||||||
|
if genre != "" {
|
||||||
|
setComment(cmt, "GENRE", genre)
|
||||||
|
}
|
||||||
|
if label != "" {
|
||||||
|
setComment(cmt, "ORGANIZATION", label)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmtBlock := cmt.Marshal()
|
||||||
|
if cmtIdx >= 0 {
|
||||||
|
f.Meta[cmtIdx] = &cmtBlock
|
||||||
|
} else {
|
||||||
|
f.Meta = append(f.Meta, &cmtBlock)
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Save(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
// ExtractLyrics extracts embedded lyrics from a FLAC file
|
||||||
func ExtractLyrics(filePath string) (string, error) {
|
func ExtractLyrics(filePath string) (string, error) {
|
||||||
f, err := flac.ParseFile(filePath)
|
f, err := flac.ParseFile(filePath)
|
||||||
@@ -381,13 +436,11 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try LYRICS tag first
|
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to UNSYNCEDLYRICS
|
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
@@ -415,16 +468,12 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Read first 4 bytes to detect file type
|
|
||||||
marker := make([]byte, 4)
|
marker := make([]byte, 4)
|
||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a FLAC file
|
|
||||||
if string(marker) == "fLaC" {
|
if string(marker) == "fLaC" {
|
||||||
// Continue reading FLAC metadata
|
|
||||||
// Read metadata block header (4 bytes)
|
|
||||||
header := make([]byte, 4)
|
header := make([]byte, 4)
|
||||||
if _, err := file.Read(header); err != nil {
|
if _, err := file.Read(header); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
@@ -435,19 +484,15 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
return AudioQuality{}, fmt.Errorf("first block is not STREAMINFO")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read STREAMINFO block (34 bytes minimum)
|
|
||||||
streamInfo := make([]byte, 34)
|
streamInfo := make([]byte, 34)
|
||||||
if _, err := file.Read(streamInfo); err != nil {
|
if _, err := file.Read(streamInfo); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read STREAMINFO: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse sample rate (20 bits starting at byte 10)
|
|
||||||
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
sampleRate := (int(streamInfo[10]) << 12) | (int(streamInfo[11]) << 4) | (int(streamInfo[12]) >> 4)
|
||||||
|
|
||||||
// Parse bits per sample (5 bits)
|
|
||||||
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
|
||||||
|
|
||||||
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
|
|
||||||
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
|
||||||
int64(streamInfo[14])<<24 |
|
int64(streamInfo[14])<<24 |
|
||||||
int64(streamInfo[15])<<16 |
|
int64(streamInfo[15])<<16 |
|
||||||
@@ -461,17 +506,14 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
file.Seek(0, 0)
|
||||||
// First 4 bytes are size, next 4 should be "ftyp"
|
|
||||||
file.Seek(0, 0) // Reset to beginning
|
|
||||||
header8 := make([]byte, 8)
|
header8 := make([]byte, 8)
|
||||||
if _, err := file.Read(header8); err != nil {
|
if _, err := file.Read(header8); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(header8[4:8]) == "ftyp" {
|
if string(header8[4:8]) == "ftyp" {
|
||||||
// It's an M4A/MP4 file, use M4A quality reader
|
file.Close()
|
||||||
file.Close() // Close before calling GetM4AQuality which opens the file again
|
|
||||||
return GetM4AQuality(filePath)
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,41 +525,33 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
// EmbedM4AMetadata embeds metadata into an M4A file using iTunes-style atoms
|
||||||
// This is a simplified implementation that writes metadata to the file
|
|
||||||
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) error {
|
||||||
// Read the entire file
|
|
||||||
data, err := os.ReadFile(filePath)
|
data, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read M4A file: %w", err)
|
return fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find moov atom position
|
|
||||||
moovPos := findAtom(data, "moov", 0)
|
moovPos := findAtom(data, "moov", 0)
|
||||||
if moovPos < 0 {
|
if moovPos < 0 {
|
||||||
return fmt.Errorf("moov atom not found in M4A file")
|
return fmt.Errorf("moov atom not found in M4A file")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find udta atom inside moov, or create one
|
|
||||||
moovSize := int(uint32(data[moovPos])<<24 | uint32(data[moovPos+1])<<16 | uint32(data[moovPos+2])<<8 | uint32(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)
|
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||||
|
|
||||||
// Build new metadata atoms
|
|
||||||
metaAtom := buildMetaAtom(metadata, coverData)
|
metaAtom := buildMetaAtom(metadata, coverData)
|
||||||
|
|
||||||
var newData []byte
|
var newData []byte
|
||||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||||
// udta exists, find meta inside it or replace
|
|
||||||
udtaSize := int(uint32(data[udtaPos])<<24 | uint32(data[udtaPos+1])<<16 | uint32(data[udtaPos+2])<<8 | uint32(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)
|
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||||
|
|
||||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||||
// Replace existing meta atom
|
|
||||||
metaSize := int(uint32(data[metaPos])<<24 | uint32(data[metaPos+1])<<16 | uint32(data[metaPos+2])<<8 | uint32(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, data[:metaPos]...)
|
||||||
newData = append(newData, metaAtom...)
|
newData = append(newData, metaAtom...)
|
||||||
newData = append(newData, data[metaPos+metaSize:]...)
|
newData = append(newData, data[metaPos+metaSize:]...)
|
||||||
} else {
|
} else {
|
||||||
// Add meta atom to udta
|
|
||||||
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
newUdtaContent := append(data[udtaPos+8:udtaPos+udtaSize], metaAtom...)
|
||||||
newUdtaSize := 8 + len(newUdtaContent)
|
newUdtaSize := 8 + len(newUdtaContent)
|
||||||
newUdta := make([]byte, 4)
|
newUdta := make([]byte, 4)
|
||||||
@@ -533,7 +567,6 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new udta with meta
|
|
||||||
udtaContent := metaAtom
|
udtaContent := metaAtom
|
||||||
udtaSize := 8 + len(udtaContent)
|
udtaSize := 8 + len(udtaContent)
|
||||||
newUdta := make([]byte, 4)
|
newUdta := make([]byte, 4)
|
||||||
@@ -544,21 +577,18 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
newUdta = append(newUdta, []byte("udta")...)
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
newUdta = append(newUdta, udtaContent...)
|
newUdta = append(newUdta, udtaContent...)
|
||||||
|
|
||||||
// Insert udta at end of moov
|
|
||||||
insertPos := moovPos + moovSize
|
insertPos := moovPos + moovSize
|
||||||
newData = append(newData, data[:insertPos]...)
|
newData = append(newData, data[:insertPos]...)
|
||||||
newData = append(newData, newUdta...)
|
newData = append(newData, newUdta...)
|
||||||
newData = append(newData, data[insertPos:]...)
|
newData = append(newData, data[insertPos:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update moov size
|
|
||||||
newMoovSize := moovSize + len(newData) - len(data)
|
newMoovSize := moovSize + len(newData) - len(data)
|
||||||
newData[moovPos] = byte(newMoovSize >> 24)
|
newData[moovPos] = byte(newMoovSize >> 24)
|
||||||
newData[moovPos+1] = byte(newMoovSize >> 16)
|
newData[moovPos+1] = byte(newMoovSize >> 16)
|
||||||
newData[moovPos+2] = byte(newMoovSize >> 8)
|
newData[moovPos+2] = byte(newMoovSize >> 8)
|
||||||
newData[moovPos+3] = byte(newMoovSize)
|
newData[moovPos+3] = byte(newMoovSize)
|
||||||
|
|
||||||
// Write back to file
|
|
||||||
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
if err := os.WriteFile(filePath, newData, 0644); err != nil {
|
||||||
return fmt.Errorf("failed to write M4A file: %w", err)
|
return fmt.Errorf("failed to write M4A file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -585,55 +615,44 @@ func findAtom(data []byte, name string, offset int) int {
|
|||||||
|
|
||||||
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
// buildMetaAtom builds a complete meta atom with ilst containing metadata
|
||||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||||
// Build ilst content
|
|
||||||
var ilst []byte
|
var ilst []byte
|
||||||
|
|
||||||
// ©nam - Title
|
|
||||||
if metadata.Title != "" {
|
if metadata.Title != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©ART - Artist
|
|
||||||
if metadata.Artist != "" {
|
if metadata.Artist != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©alb - Album
|
|
||||||
if metadata.Album != "" {
|
if metadata.Album != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// aART - Album Artist
|
|
||||||
if metadata.AlbumArtist != "" {
|
if metadata.AlbumArtist != "" {
|
||||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©day - Year/Date
|
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// trkn - Track Number
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk - Disc Number
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©lyr - Lyrics
|
|
||||||
if metadata.Lyrics != "" {
|
if metadata.Lyrics != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// covr - Cover Art
|
|
||||||
if len(coverData) > 0 {
|
if len(coverData) > 0 {
|
||||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build ilst atom
|
|
||||||
ilstSize := 8 + len(ilst)
|
ilstSize := 8 + len(ilst)
|
||||||
ilstAtom := make([]byte, 4)
|
ilstAtom := make([]byte, 4)
|
||||||
ilstAtom[0] = byte(ilstSize >> 24)
|
ilstAtom[0] = byte(ilstSize >> 24)
|
||||||
@@ -643,7 +662,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||||
ilstAtom = append(ilstAtom, ilst...)
|
ilstAtom = append(ilstAtom, ilst...)
|
||||||
|
|
||||||
// Build hdlr atom (required for meta)
|
|
||||||
hdlr := []byte{
|
hdlr := []byte{
|
||||||
0, 0, 0, 33, // size = 33
|
0, 0, 0, 33, // size = 33
|
||||||
'h', 'd', 'l', 'r',
|
'h', 'd', 'l', 'r',
|
||||||
@@ -656,7 +674,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
0, // null terminator
|
0, // null terminator
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build meta atom
|
|
||||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||||
metaContent = append(metaContent, ilstAtom...)
|
metaContent = append(metaContent, ilstAtom...)
|
||||||
|
|
||||||
@@ -676,7 +693,6 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
func buildTextAtom(name, value string) []byte {
|
func buildTextAtom(name, value string) []byte {
|
||||||
valueBytes := []byte(value)
|
valueBytes := []byte(value)
|
||||||
|
|
||||||
// data atom
|
|
||||||
dataSize := 16 + len(valueBytes)
|
dataSize := 16 + len(valueBytes)
|
||||||
dataAtom := make([]byte, 4)
|
dataAtom := make([]byte, 4)
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
@@ -688,7 +704,6 @@ func buildTextAtom(name, value string) []byte {
|
|||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
dataAtom = append(dataAtom, valueBytes...)
|
dataAtom = append(dataAtom, valueBytes...)
|
||||||
|
|
||||||
// container atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -703,7 +718,6 @@ func buildTextAtom(name, value string) []byte {
|
|||||||
|
|
||||||
// buildTrackNumberAtom builds trkn atom
|
// buildTrackNumberAtom builds trkn atom
|
||||||
func buildTrackNumberAtom(track, total int) []byte {
|
func buildTrackNumberAtom(track, total int) []byte {
|
||||||
// data atom with track number
|
|
||||||
dataAtom := []byte{
|
dataAtom := []byte{
|
||||||
0, 0, 0, 24, // size
|
0, 0, 0, 24, // size
|
||||||
'd', 'a', 't', 'a',
|
'd', 'a', 't', 'a',
|
||||||
@@ -715,7 +729,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
|||||||
0, 0, // padding
|
0, 0, // padding
|
||||||
}
|
}
|
||||||
|
|
||||||
// trkn atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -730,7 +743,6 @@ func buildTrackNumberAtom(track, total int) []byte {
|
|||||||
|
|
||||||
// buildDiscNumberAtom builds disk atom
|
// buildDiscNumberAtom builds disk atom
|
||||||
func buildDiscNumberAtom(disc, total int) []byte {
|
func buildDiscNumberAtom(disc, total int) []byte {
|
||||||
// data atom with disc number
|
|
||||||
dataAtom := []byte{
|
dataAtom := []byte{
|
||||||
0, 0, 0, 22, // size
|
0, 0, 0, 22, // size
|
||||||
'd', 'a', 't', 'a',
|
'd', 'a', 't', 'a',
|
||||||
@@ -741,7 +753,6 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
|||||||
byte(total >> 8), byte(total), // total discs
|
byte(total >> 8), byte(total), // total discs
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -756,13 +767,11 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
|||||||
|
|
||||||
// buildCoverAtom builds covr atom with image data
|
// buildCoverAtom builds covr atom with image data
|
||||||
func buildCoverAtom(coverData []byte) []byte {
|
func buildCoverAtom(coverData []byte) []byte {
|
||||||
// Detect image type (JPEG = 13, PNG = 14)
|
|
||||||
imageType := byte(13) // default JPEG
|
imageType := byte(13) // default JPEG
|
||||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||||
imageType = 14 // PNG
|
imageType = 14 // PNG
|
||||||
}
|
}
|
||||||
|
|
||||||
// data atom
|
|
||||||
dataSize := 16 + len(coverData)
|
dataSize := 16 + len(coverData)
|
||||||
dataAtom := make([]byte, 4)
|
dataAtom := make([]byte, 4)
|
||||||
dataAtom[0] = byte(dataSize >> 24)
|
dataAtom[0] = byte(dataSize >> 24)
|
||||||
@@ -774,7 +783,6 @@ func buildCoverAtom(coverData []byte) []byte {
|
|||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
dataAtom = append(dataAtom, coverData...)
|
dataAtom = append(dataAtom, coverData...)
|
||||||
|
|
||||||
// covr atom
|
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
atom[0] = byte(atomSize >> 24)
|
atom[0] = byte(atomSize >> 24)
|
||||||
@@ -794,24 +802,18 @@ func GetM4AQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find moov -> trak -> mdia -> minf -> stbl -> stsd
|
|
||||||
moovPos := findAtom(data, "moov", 0)
|
moovPos := findAtom(data, "moov", 0)
|
||||||
if moovPos < 0 {
|
if moovPos < 0 {
|
||||||
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
return AudioQuality{}, fmt.Errorf("moov atom not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search for mp4a or alac atom which contains audio info
|
|
||||||
// This is a simplified search - real implementation would traverse the atom tree
|
|
||||||
for i := moovPos; i < len(data)-20; i++ {
|
for i := moovPos; i < len(data)-20; i++ {
|
||||||
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
if string(data[i:i+4]) == "mp4a" || string(data[i:i+4]) == "alac" {
|
||||||
// Sample rate is at offset 22-23 from atom start (16-bit big-endian)
|
|
||||||
if i+24 < len(data) {
|
if i+24 < len(data) {
|
||||||
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
sampleRate := int(data[i+22])<<8 | int(data[i+23])
|
||||||
// For AAC, bit depth is typically 16
|
|
||||||
bitDepth := 16
|
bitDepth := 16
|
||||||
if string(data[i:i+4]) == "alac" {
|
if string(data[i:i+4]) == "alac" {
|
||||||
// ALAC can have higher bit depth, check esds or alac specific data
|
bitDepth = 24
|
||||||
bitDepth = 24 // Assume 24-bit for ALAC
|
|
||||||
}
|
}
|
||||||
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
return AudioQuality{BitDepth: bitDepth, SampleRate: sampleRate}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func GetTrackIDCache() *TrackIDCache {
|
|||||||
trackIDCacheOnce.Do(func() {
|
trackIDCacheOnce.Do(func() {
|
||||||
globalTrackIDCache = &TrackIDCache{
|
globalTrackIDCache = &TrackIDCache{
|
||||||
cache: make(map[string]*TrackIDCacheEntry),
|
cache: make(map[string]*TrackIDCacheEntry),
|
||||||
ttl: 30 * time.Minute, // Cache for 30 minutes
|
ttl: 30 * time.Minute,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalTrackIDCache
|
return globalTrackIDCache
|
||||||
@@ -124,6 +124,7 @@ type ParallelDownloadResult struct {
|
|||||||
|
|
||||||
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
|
||||||
// This runs while the main audio download is happening
|
// This runs while the main audio download is happening
|
||||||
|
// durationMs: track duration in milliseconds for lyrics matching
|
||||||
func FetchCoverAndLyricsParallel(
|
func FetchCoverAndLyricsParallel(
|
||||||
coverURL string,
|
coverURL string,
|
||||||
maxQualityCover bool,
|
maxQualityCover bool,
|
||||||
@@ -131,11 +132,11 @@ func FetchCoverAndLyricsParallel(
|
|||||||
trackName string,
|
trackName string,
|
||||||
artistName string,
|
artistName string,
|
||||||
embedLyrics bool,
|
embedLyrics bool,
|
||||||
|
durationMs int64,
|
||||||
) *ParallelDownloadResult {
|
) *ParallelDownloadResult {
|
||||||
result := &ParallelDownloadResult{}
|
result := &ParallelDownloadResult{}
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
// Download cover in parallel
|
|
||||||
if coverURL != "" {
|
if coverURL != "" {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -159,13 +160,13 @@ func FetchCoverAndLyricsParallel(
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
fmt.Println("[Parallel] Starting lyrics fetch...")
|
fmt.Println("[Parallel] Starting lyrics fetch...")
|
||||||
client := NewLyricsClient()
|
client := NewLyricsClient()
|
||||||
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
|
durationSec := float64(durationMs) / 1000.0
|
||||||
|
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.LyricsErr = err
|
result.LyricsErr = err
|
||||||
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
|
||||||
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
} else if lyrics != nil && len(lyrics.Lines) > 0 {
|
||||||
result.LyricsData = lyrics
|
result.LyricsData = lyrics
|
||||||
// Use LRC with metadata headers (like PC version)
|
|
||||||
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
result.LyricsLRC = convertToLRCWithMetadata(lyrics, trackName, artistName)
|
||||||
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
|
||||||
} else {
|
} else {
|
||||||
@@ -202,12 +203,10 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) {
|
|||||||
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
|
||||||
cache := GetTrackIDCache()
|
cache := GetTrackIDCache()
|
||||||
|
|
||||||
// Limit concurrent pre-warm requests
|
semaphore := make(chan struct{}, 3)
|
||||||
semaphore := make(chan struct{}, 3) // Max 3 concurrent
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, req := range requests {
|
for _, req := range requests {
|
||||||
// Skip if already cached
|
|
||||||
if cached := cache.Get(req.ISRC); cached != nil {
|
if cached := cache.Get(req.ISRC); cached != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -252,11 +251,9 @@ func preWarmQobuzCache(isrc string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func preWarmAmazonCache(isrc, spotifyID string) {
|
func preWarmAmazonCache(isrc, spotifyID string) {
|
||||||
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
|
|
||||||
client := NewSongLinkClient()
|
client := NewSongLinkClient()
|
||||||
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
|
||||||
if err == nil && availability != nil && availability.Amazon {
|
if err == nil && availability != nil && availability.Amazon {
|
||||||
// Store Amazon URL in cache (using ISRC as key)
|
|
||||||
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
|
||||||
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
|
||||||
}
|
}
|
||||||
@@ -270,10 +267,8 @@ func preWarmAmazonCache(isrc, spotifyID string) {
|
|||||||
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
|
||||||
func PreWarmCache(tracksJSON string) error {
|
func PreWarmCache(tracksJSON string) error {
|
||||||
var requests []PreWarmCacheRequest
|
var requests []PreWarmCacheRequest
|
||||||
// Parse JSON (simplified - in production use proper JSON parsing)
|
|
||||||
// For now, this is called from exports.go with proper parsing
|
|
||||||
|
|
||||||
go PreWarmTrackCache(requests) // Run in background
|
go PreWarmTrackCache(requests)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,16 +44,14 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// getProgress returns current download progress from multi-progress system
|
// getProgress returns current download progress from multi-progress system
|
||||||
// Returns first active item's progress for backward compatibility
|
|
||||||
func getProgress() DownloadProgress {
|
func getProgress() DownloadProgress {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
|
|
||||||
// Find first active item
|
|
||||||
for _, item := range multiProgress.Items {
|
for _, item := range multiProgress.Items {
|
||||||
return DownloadProgress{
|
return DownloadProgress{
|
||||||
CurrentFile: item.ItemID,
|
CurrentFile: item.ItemID,
|
||||||
Progress: item.Progress * 100, // Convert to percentage
|
Progress: item.Progress * 100,
|
||||||
BytesTotal: item.BytesTotal,
|
BytesTotal: item.BytesTotal,
|
||||||
BytesReceived: item.BytesReceived,
|
BytesReceived: item.BytesReceived,
|
||||||
IsDownloading: item.IsDownloading,
|
IsDownloading: item.IsDownloading,
|
||||||
@@ -240,16 +238,16 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str
|
|||||||
|
|
||||||
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
// Write implements io.Writer with threshold-based progress updates and speed tracking
|
||||||
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
||||||
|
if pw.itemID != "" && isDownloadCancelled(pw.itemID) {
|
||||||
|
return 0, ErrDownloadCancelled
|
||||||
|
}
|
||||||
n, err := pw.writer.Write(p)
|
n, err := pw.writer.Write(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return n, err
|
return n, err
|
||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
|
|
||||||
// Update progress when we've received at least 64KB since last update
|
|
||||||
// Also update on first write to show download has started
|
|
||||||
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
if pw.lastReported == 0 || pw.current-pw.lastReported >= progressUpdateThreshold {
|
||||||
// Calculate speed (MB/s) based on bytes received since last update
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
elapsed := now.Sub(pw.lastTime).Seconds()
|
elapsed := now.Sub(pw.lastTime).Seconds()
|
||||||
var speedMBps float64
|
var speedMBps float64
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -23,7 +25,6 @@ type QobuzDownloader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Qobuz downloader instance for connection reuse
|
|
||||||
globalQobuzDownloader *QobuzDownloader
|
globalQobuzDownloader *QobuzDownloader
|
||||||
qobuzDownloaderOnce sync.Once
|
qobuzDownloaderOnce sync.Once
|
||||||
)
|
)
|
||||||
@@ -64,22 +65,17 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split expected artists by common separators (comma, feat, ft., &, and)
|
|
||||||
// e.g., "RADWIMPS, Toko Miura" or "RADWIMPS feat. Toko Miura"
|
|
||||||
expectedArtists := qobuzSplitArtists(normExpected)
|
expectedArtists := qobuzSplitArtists(normExpected)
|
||||||
foundArtists := qobuzSplitArtists(normFound)
|
foundArtists := qobuzSplitArtists(normFound)
|
||||||
|
|
||||||
// Check if ANY expected artist matches ANY found artist
|
|
||||||
for _, exp := range expectedArtists {
|
for _, exp := range expectedArtists {
|
||||||
for _, fnd := range foundArtists {
|
for _, fnd := range foundArtists {
|
||||||
if exp == fnd {
|
if exp == fnd {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Also check contains for partial matches
|
|
||||||
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
if strings.Contains(exp, fnd) || strings.Contains(fnd, exp) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Check same words different order
|
|
||||||
if qobuzSameWordsUnordered(exp, fnd) {
|
if qobuzSameWordsUnordered(exp, fnd) {
|
||||||
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
GoLog("[Qobuz] Artist names have same words in different order: '%s' vs '%s'\n", exp, fnd)
|
||||||
return true
|
return true
|
||||||
@@ -87,8 +83,6 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If scripts are TRULY different (Latin vs CJK/Arabic/Cyrillic), assume match (transliteration)
|
|
||||||
// Don't treat Latin Extended (Polish, French, etc.) as different script
|
|
||||||
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
expectedLatin := qobuzIsLatinScript(expectedArtist)
|
||||||
foundLatin := qobuzIsLatinScript(foundArtist)
|
foundLatin := qobuzIsLatinScript(foundArtist)
|
||||||
if expectedLatin != foundLatin {
|
if expectedLatin != foundLatin {
|
||||||
@@ -367,6 +361,35 @@ func NewQobuzDownloader() *QobuzDownloader {
|
|||||||
return globalQobuzDownloader
|
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
|
// GetAvailableAPIs returns list of available Qobuz APIs
|
||||||
// Uses same APIs as PC version for compatibility
|
// Uses same APIs as PC version for compatibility
|
||||||
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
func (q *QobuzDownloader) GetAvailableAPIs() []string {
|
||||||
@@ -824,7 +847,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
return "", fmt.Errorf("no Qobuz API available")
|
return "", fmt.Errorf("no Qobuz API available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use parallel approach - request from all APIs simultaneously
|
|
||||||
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
_, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -835,19 +857,30 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// Initialize item progress (required for all downloads)
|
// Initialize item progress (required for all downloads)
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(q.client, req)
|
resp, err := DoRequestWithUserAgent(q.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -857,7 +890,6 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -867,16 +899,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,9 +913,11 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -925,19 +956,31 @@ type QobuzDownloadResult struct {
|
|||||||
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
||||||
downloader := NewQobuzDownloader()
|
downloader := NewQobuzDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert expected duration from ms to seconds
|
|
||||||
expectedDurationSec := req.DurationMS / 1000
|
expectedDurationSec := req.DurationMS / 1000
|
||||||
|
|
||||||
var track *QobuzTrack
|
var track *QobuzTrack
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
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
|
// 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 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
|
||||||
GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
|
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
|
// For Qobuz we need to search again to get full track info, but we can use the ID
|
||||||
@@ -992,7 +1035,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -1004,7 +1046,6 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return QobuzDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
@@ -1023,12 +1064,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
|
||||||
|
|
||||||
// Get actual quality from track metadata
|
|
||||||
actualBitDepth := track.MaximumBitDepth
|
actualBitDepth := track.MaximumBitDepth
|
||||||
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
actualSampleRate := int(track.MaximumSamplingRate * 1000) // Convert kHz to Hz
|
||||||
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
GoLog("[Qobuz] Actual quality: %d-bit/%.1fkHz\n", actualBitDepth, track.MaximumSamplingRate)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
|
||||||
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
downloadURL, err := downloader.GetDownloadURL(track.ID, qobuzQuality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -1046,27 +1085,26 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Download audio file with item ID for progress tracking
|
// Download audio file with item ID for progress tracking
|
||||||
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
|
||||||
|
if errors.Is(err, ErrDownloadCancelled) {
|
||||||
|
return QobuzDownloadResult{}, ErrDownloadCancelled
|
||||||
|
}
|
||||||
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Embed metadata using parallel-fetched cover data
|
|
||||||
// Use metadata from the actual Qobuz track found (more accurate than request) but prefer
|
|
||||||
// requested Album Name to avoid ISRC version mismatches (e.g. Compilations vs Original)
|
|
||||||
albumName := track.Album.Title
|
albumName := track.Album.Title
|
||||||
if req.AlbumName != "" {
|
if req.AlbumName != "" {
|
||||||
albumName = req.AlbumName
|
albumName = req.AlbumName
|
||||||
@@ -1082,9 +1120,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
|
|||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
DiscNumber: req.DiscNumber, // QobuzTrack struct usually doesn't have disc info in simple search result
|
||||||
ISRC: track.ISRC,
|
ISRC: track.ISRC,
|
||||||
|
Genre: req.Genre, // From Deezer album metadata
|
||||||
|
Label: req.Label, // From Deezer album metadata
|
||||||
|
Copyright: req.Copyright, // From Deezer album metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
|
|||||||
@@ -30,31 +30,25 @@ func (r *RateLimiter) WaitForSlot() {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// Remove timestamps outside the window
|
|
||||||
r.cleanOldTimestamps(now)
|
r.cleanOldTimestamps(now)
|
||||||
|
|
||||||
// If under limit, record and return immediately
|
|
||||||
if len(r.timestamps) < r.maxRequests {
|
if len(r.timestamps) < r.maxRequests {
|
||||||
r.timestamps = append(r.timestamps, now)
|
r.timestamps = append(r.timestamps, now)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate wait time until oldest timestamp expires
|
|
||||||
oldestTimestamp := r.timestamps[0]
|
oldestTimestamp := r.timestamps[0]
|
||||||
waitUntil := oldestTimestamp.Add(r.window)
|
waitUntil := oldestTimestamp.Add(r.window)
|
||||||
waitDuration := waitUntil.Sub(now)
|
waitDuration := waitUntil.Sub(now)
|
||||||
|
|
||||||
if waitDuration > 0 {
|
if waitDuration > 0 {
|
||||||
// Release lock while waiting
|
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
time.Sleep(waitDuration)
|
time.Sleep(waitDuration)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|
||||||
// Clean again after waiting
|
|
||||||
r.cleanOldTimestamps(time.Now())
|
r.cleanOldTimestamps(time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record this request
|
|
||||||
r.timestamps = append(r.timestamps, time.Now())
|
r.timestamps = append(r.timestamps, time.Now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ type TrackAvailability struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global SongLink client instance for connection reuse
|
|
||||||
globalSongLinkClient *SongLinkClient
|
globalSongLinkClient *SongLinkClient
|
||||||
songLinkClientOnce sync.Once
|
songLinkClientOnce sync.Once
|
||||||
)
|
)
|
||||||
@@ -40,7 +39,7 @@ var (
|
|||||||
func NewSongLinkClient() *SongLinkClient {
|
func NewSongLinkClient() *SongLinkClient {
|
||||||
songLinkClientOnce.Do(func() {
|
songLinkClientOnce.Do(func() {
|
||||||
globalSongLinkClient = &SongLinkClient{
|
globalSongLinkClient = &SongLinkClient{
|
||||||
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
|
client: NewHTTPClientWithTimeout(SongLinkTimeout),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalSongLinkClient
|
return globalSongLinkClient
|
||||||
@@ -48,15 +47,12 @@ func NewSongLinkClient() *SongLinkClient {
|
|||||||
|
|
||||||
// CheckTrackAvailability checks track availability on streaming platforms
|
// CheckTrackAvailability checks track availability on streaming platforms
|
||||||
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
|
||||||
// Validate Spotify ID format (should be 22 characters alphanumeric)
|
|
||||||
if spotifyTrackID == "" {
|
if spotifyTrackID == "" {
|
||||||
return nil, fmt.Errorf("spotify track ID is empty")
|
return nil, fmt.Errorf("spotify track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter - blocks until request is allowed
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build API URL
|
|
||||||
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
|
||||||
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
|
||||||
|
|
||||||
@@ -68,7 +64,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use retry logic with User-Agent
|
|
||||||
retryConfig := DefaultRetryConfig()
|
retryConfig := DefaultRetryConfig()
|
||||||
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
resp, err := DoRequestWithRetry(s.client, req, retryConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,7 +71,6 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// Handle specific error codes
|
|
||||||
if resp.StatusCode == 400 {
|
if resp.StatusCode == 400 {
|
||||||
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
return nil, fmt.Errorf("track not found on SongLink (invalid Spotify ID or track unavailable)")
|
||||||
}
|
}
|
||||||
@@ -109,27 +103,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
|
|||||||
SpotifyID: spotifyTrackID,
|
SpotifyID: spotifyTrackID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
// Extract Deezer ID from URL (e.g., https://www.deezer.com/track/123456)
|
|
||||||
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Qobuz using ISRC (SongLink doesn't support Qobuz directly)
|
|
||||||
if isrc != "" {
|
if isrc != "" {
|
||||||
availability.Qobuz = checkQobuzAvailability(isrc)
|
availability.Qobuz = checkQobuzAvailability(isrc)
|
||||||
}
|
}
|
||||||
@@ -191,12 +180,9 @@ func checkQobuzAvailability(isrc string) bool {
|
|||||||
|
|
||||||
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
// extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL
|
||||||
func extractDeezerIDFromURL(deezerURL string) string {
|
func extractDeezerIDFromURL(deezerURL string) string {
|
||||||
// URL format: https://www.deezer.com/track/123456 or https://www.deezer.com/en/track/123456
|
|
||||||
parts := strings.Split(deezerURL, "/")
|
parts := strings.Split(deezerURL, "/")
|
||||||
if len(parts) > 0 {
|
if len(parts) > 0 {
|
||||||
// Get the last part which should be the ID
|
|
||||||
lastPart := parts[len(parts)-1]
|
lastPart := parts[len(parts)-1]
|
||||||
// Remove any query parameters
|
|
||||||
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
if idx := strings.Index(lastPart, "?"); idx > 0 {
|
||||||
lastPart = lastPart[:idx]
|
lastPart = lastPart[:idx]
|
||||||
}
|
}
|
||||||
@@ -274,7 +260,6 @@ func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAv
|
|||||||
SpotifyID: spotifyAlbumID,
|
SpotifyID: spotifyAlbumID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
@@ -309,13 +294,10 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
return nil, fmt.Errorf("deezer track ID is empty")
|
return nil, fmt.Errorf("deezer track ID is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use global rate limiter
|
|
||||||
songLinkRateLimiter.WaitForSlot()
|
songLinkRateLimiter.WaitForSlot()
|
||||||
|
|
||||||
// Build Deezer URL
|
|
||||||
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID)
|
||||||
|
|
||||||
// Build API URL using Deezer URL as source
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
|
||||||
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
apiURL := fmt.Sprintf("%s%s&userCountry=US", string(apiBase), url.QueryEscape(deezerURL))
|
||||||
|
|
||||||
@@ -371,25 +353,20 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra
|
|||||||
DeezerID: deezerTrackID,
|
DeezerID: deezerTrackID,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Spotify
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
// Extract Spotify ID from URL
|
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer URL
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
}
|
}
|
||||||
@@ -459,24 +436,20 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
availability := &TrackAvailability{}
|
availability := &TrackAvailability{}
|
||||||
|
|
||||||
// Check Spotify
|
|
||||||
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
if spotifyLink, ok := songLinkResp.LinksByPlatform["spotify"]; ok && spotifyLink.URL != "" {
|
||||||
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
availability.SpotifyID = extractSpotifyIDFromURL(spotifyLink.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Tidal
|
|
||||||
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
|
||||||
availability.Tidal = true
|
availability.Tidal = true
|
||||||
availability.TidalURL = tidalLink.URL
|
availability.TidalURL = tidalLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Amazon
|
|
||||||
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
|
||||||
availability.Amazon = true
|
availability.Amazon = true
|
||||||
availability.AmazonURL = amazonLink.URL
|
availability.AmazonURL = amazonLink.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check Deezer
|
|
||||||
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
|
||||||
availability.Deezer = true
|
availability.Deezer = true
|
||||||
availability.DeezerURL = deezerLink.URL
|
availability.DeezerURL = deezerLink.URL
|
||||||
@@ -488,10 +461,8 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
|
|||||||
|
|
||||||
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
// extractSpotifyIDFromURL extracts Spotify track ID from URL
|
||||||
func extractSpotifyIDFromURL(spotifyURL string) string {
|
func extractSpotifyIDFromURL(spotifyURL string) string {
|
||||||
// URL format: https://open.spotify.com/track/0Jcij1eWd5bDMU5iPbxe2i
|
|
||||||
parts := strings.Split(spotifyURL, "/track/")
|
parts := strings.Split(spotifyURL, "/track/")
|
||||||
if len(parts) > 1 {
|
if len(parts) > 1 {
|
||||||
// Get the ID part and remove any query parameters
|
|
||||||
idPart := parts[1]
|
idPart := parts[1]
|
||||||
if idx := strings.Index(idPart, "?"); idx > 0 {
|
if idx := strings.Index(idPart, "?"); idx > 0 {
|
||||||
idPart = idPart[:idx]
|
idPart = idPart[:idx]
|
||||||
|
|||||||
@@ -84,12 +84,10 @@ func HasSpotifyCredentials() bool {
|
|||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
// Check custom credentials first
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check environment variables
|
|
||||||
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -102,12 +100,10 @@ func getCredentials() (string, string, error) {
|
|||||||
credentialsMu.RLock()
|
credentialsMu.RLock()
|
||||||
defer credentialsMu.RUnlock()
|
defer credentialsMu.RUnlock()
|
||||||
|
|
||||||
// Check custom credentials first
|
|
||||||
if customClientID != "" && customClientSecret != "" {
|
if customClientID != "" && customClientSecret != "" {
|
||||||
return customClientID, customClientSecret, nil
|
return customClientID, customClientSecret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check environment variables
|
|
||||||
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
|
||||||
@@ -115,14 +111,12 @@ func getCredentials() (string, string, error) {
|
|||||||
return clientID, clientSecret, nil
|
return clientID, clientSecret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// No credentials available
|
|
||||||
return "", "", ErrNoSpotifyCredentials
|
return "", "", ErrNoSpotifyCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSpotifyMetadataClient creates a new Spotify client
|
// NewSpotifyMetadataClient creates a new Spotify client
|
||||||
// Returns error if credentials are not configured
|
// Returns error if credentials are not configured
|
||||||
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
||||||
// Get credentials - will error if not configured
|
|
||||||
clientID, clientSecret, err := getCredentials()
|
clientID, clientSecret, err := getCredentials()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -131,7 +125,7 @@ func NewSpotifyMetadataClient() (*SpotifyMetadataClient, error) {
|
|||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
|
|
||||||
c := &SpotifyMetadataClient{
|
c := &SpotifyMetadataClient{
|
||||||
httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling
|
httpClient: NewHTTPClientWithTimeout(15 * time.Second),
|
||||||
clientID: clientID,
|
clientID: clientID,
|
||||||
clientSecret: clientSecret,
|
clientSecret: clientSecret,
|
||||||
rng: rand.New(src),
|
rng: rand.New(src),
|
||||||
@@ -188,6 +182,9 @@ type AlbumInfoMetadata struct {
|
|||||||
ReleaseDate string `json:"release_date"`
|
ReleaseDate string `json:"release_date"`
|
||||||
Artists string `json:"artists"`
|
Artists string `json:"artists"`
|
||||||
Images string `json:"images"`
|
Images string `json:"images"`
|
||||||
|
Genre string `json:"genre,omitempty"` // Music genre(s), comma-separated
|
||||||
|
Label string `json:"label,omitempty"` // Record label name
|
||||||
|
Copyright string `json:"copyright,omitempty"` // Copyright information
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumResponsePayload is the response for album requests
|
// AlbumResponsePayload is the response for album requests
|
||||||
@@ -393,10 +390,8 @@ func (c *SpotifyMetadataClient) SearchTracks(ctx context.Context, query string,
|
|||||||
|
|
||||||
// SearchAll searches for tracks and artists on Spotify
|
// SearchAll searches for tracks and artists on Spotify
|
||||||
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
// Create cache key
|
|
||||||
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -456,7 +451,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit artists to artistLimit
|
|
||||||
artistCount := len(response.Artists.Items)
|
artistCount := len(response.Artists.Items)
|
||||||
if artistCount > artistLimit {
|
if artistCount > artistLimit {
|
||||||
artistCount = artistLimit
|
artistCount = artistLimit
|
||||||
@@ -473,7 +467,6 @@ func (c *SpotifyMetadataClient) SearchAll(ctx context.Context, query string, tra
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -510,7 +503,6 @@ func (c *SpotifyMetadataClient) fetchTrack(ctx context.Context, trackID, token s
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token string) (*AlbumResponsePayload, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
if entry, ok := c.albumCache[albumID]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -610,7 +602,6 @@ func (c *SpotifyMetadataClient) fetchAlbum(ctx context.Context, albumID, token s
|
|||||||
TrackList: tracks,
|
TrackList: tracks,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.albumCache[albumID] = &cacheEntry{
|
c.albumCache[albumID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
@@ -768,7 +759,6 @@ func (c *SpotifyMetadataClient) fetchPlaylist(ctx context.Context, playlistID, t
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token string) (*ArtistResponsePayload, error) {
|
||||||
// Check cache first
|
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
if entry, ok := c.artistCache[artistID]; ok && !entry.isExpired() {
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -856,7 +846,6 @@ func (c *SpotifyMetadataClient) fetchArtist(ctx context.Context, artistID, token
|
|||||||
Albums: albums,
|
Albums: albums,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.artistCache[artistID] = &cacheEntry{
|
c.artistCache[artistID] = &cacheEntry{
|
||||||
data: result,
|
data: result,
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package gobackend
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -29,7 +31,6 @@ type TidalDownloader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// Global Tidal downloader instance for token reuse
|
|
||||||
globalTidalDownloader *TidalDownloader
|
globalTidalDownloader *TidalDownloader
|
||||||
tidalDownloaderOnce sync.Once
|
tidalDownloaderOnce sync.Once
|
||||||
)
|
)
|
||||||
@@ -116,7 +117,6 @@ func NewTidalDownloader() *TidalDownloader {
|
|||||||
clientSecret: string(clientSecret),
|
clientSecret: string(clientSecret),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get first available API
|
|
||||||
apis := globalTidalDownloader.GetAvailableAPIs()
|
apis := globalTidalDownloader.GetAvailableAPIs()
|
||||||
if len(apis) > 0 {
|
if len(apis) > 0 {
|
||||||
globalTidalDownloader.apiURL = apis[0]
|
globalTidalDownloader.apiURL = apis[0]
|
||||||
@@ -128,16 +128,14 @@ func NewTidalDownloader() *TidalDownloader {
|
|||||||
// GetAvailableAPIs returns list of available Tidal APIs
|
// GetAvailableAPIs returns list of available Tidal APIs
|
||||||
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
func (t *TidalDownloader) GetAvailableAPIs() []string {
|
||||||
encodedAPIs := []string{
|
encodedAPIs := []string{
|
||||||
// Priority 1: APIs that return FULL tracks (not PREVIEW)
|
"dGlkYWwua2lub3BsdXMub25saW5l",
|
||||||
"dGlkYWwua2lub3BsdXMub25saW5l", // tidal.kinoplus.online - returns FULL
|
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn",
|
||||||
"dGlkYWwtYXBpLmJpbmltdW0ub3Jn", // tidal-api.binimum.org
|
"dHJpdG9uLnNxdWlkLnd0Zg==",
|
||||||
"dHJpdG9uLnNxdWlkLnd0Zg==", // triton.squid.wtf
|
"dm9nZWwucXFkbC5zaXRl",
|
||||||
// Priority 2: qqdl.site APIs (often return PREVIEW only)
|
"bWF1cy5xcWRsLnNpdGU=",
|
||||||
"dm9nZWwucXFkbC5zaXRl", // vogel.qqdl.site
|
"aHVuZC5xcWRsLnNpdGU=",
|
||||||
"bWF1cy5xcWRsLnNpdGU=", // maus.qqdl.site
|
"a2F0emUucXFkbC5zaXRl",
|
||||||
"aHVuZC5xcWRsLnNpdGU=", // hund.qqdl.site
|
"d29sZi5xcWRsLnNpdGU=",
|
||||||
"a2F0emUucXFkbC5zaXRl", // katze.qqdl.site
|
|
||||||
"d29sZi5xcWRsLnNpdGU=", // wolf.qqdl.site
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var apis []string
|
var apis []string
|
||||||
@@ -157,7 +155,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|||||||
t.tokenMu.Lock()
|
t.tokenMu.Lock()
|
||||||
defer t.tokenMu.Unlock()
|
defer t.tokenMu.Unlock()
|
||||||
|
|
||||||
// Return cached token if still valid (with 60s buffer)
|
|
||||||
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
|
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
|
||||||
return t.cachedToken, nil
|
return t.cachedToken, nil
|
||||||
}
|
}
|
||||||
@@ -192,7 +189,6 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the token
|
|
||||||
t.cachedToken = result.AccessToken
|
t.cachedToken = result.AccessToken
|
||||||
if result.ExpiresIn > 0 {
|
if result.ExpiresIn > 0 {
|
||||||
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
|
||||||
@@ -384,22 +380,17 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
queries = append(queries, artistName+" "+trackName)
|
queries = append(queries, artistName+" "+trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 2: Track name only
|
|
||||||
if trackName != "" {
|
if trackName != "" {
|
||||||
queries = append(queries, trackName)
|
queries = append(queries, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 3: Romaji versions if Japanese detected (NEW - from PC version)
|
|
||||||
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
if ContainsJapanese(trackName) || ContainsJapanese(artistName) {
|
||||||
// Convert to romaji (hiragana/katakana only, kanji stays)
|
|
||||||
romajiTrack := JapaneseToRomaji(trackName)
|
romajiTrack := JapaneseToRomaji(trackName)
|
||||||
romajiArtist := JapaneseToRomaji(artistName)
|
romajiArtist := JapaneseToRomaji(artistName)
|
||||||
|
|
||||||
// Clean and remove ALL non-ASCII characters (including kanji)
|
|
||||||
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
cleanRomajiTrack := CleanToASCII(romajiTrack)
|
||||||
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
cleanRomajiArtist := CleanToASCII(romajiArtist)
|
||||||
|
|
||||||
// Artist + Track romaji (cleaned to ASCII only)
|
|
||||||
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
if cleanRomajiArtist != "" && cleanRomajiTrack != "" {
|
||||||
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
romajiQuery := cleanRomajiArtist + " " + cleanRomajiTrack
|
||||||
if !containsQuery(queries, romajiQuery) {
|
if !containsQuery(queries, romajiQuery) {
|
||||||
@@ -408,14 +399,12 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track romaji only (cleaned)
|
|
||||||
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
if cleanRomajiTrack != "" && cleanRomajiTrack != trackName {
|
||||||
if !containsQuery(queries, cleanRomajiTrack) {
|
if !containsQuery(queries, cleanRomajiTrack) {
|
||||||
queries = append(queries, cleanRomajiTrack)
|
queries = append(queries, cleanRomajiTrack)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try with partial romaji (artist + cleaned track)
|
|
||||||
if artistName != "" && cleanRomajiTrack != "" {
|
if artistName != "" && cleanRomajiTrack != "" {
|
||||||
partialQuery := artistName + " " + cleanRomajiTrack
|
partialQuery := artistName + " " + cleanRomajiTrack
|
||||||
if !containsQuery(queries, partialQuery) {
|
if !containsQuery(queries, partialQuery) {
|
||||||
@@ -424,7 +413,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy 4: Artist only as last resort
|
|
||||||
if artistName != "" {
|
if artistName != "" {
|
||||||
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
artistOnly := CleanToASCII(JapaneseToRomaji(artistName))
|
||||||
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
if artistOnly != "" && !containsQuery(queries, artistOnly) {
|
||||||
@@ -434,7 +422,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
searchBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkudGlkYWwuY29tL3YxL3NlYXJjaC90cmFja3M/cXVlcnk9")
|
||||||
|
|
||||||
// Collect all search results from all queries
|
|
||||||
var allTracks []TidalTrack
|
var allTracks []TidalTrack
|
||||||
searchedQueries := make(map[string]bool)
|
searchedQueries := make(map[string]bool)
|
||||||
|
|
||||||
@@ -484,7 +471,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
for i := range result.Items {
|
for i := range result.Items {
|
||||||
if result.Items[i].ISRC == spotifyISRC {
|
if result.Items[i].ISRC == spotifyISRC {
|
||||||
track := &result.Items[i]
|
track := &result.Items[i]
|
||||||
// Verify duration if provided
|
|
||||||
if expectedDuration > 0 {
|
if expectedDuration > 0 {
|
||||||
durationDiff := track.Duration - expectedDuration
|
durationDiff := track.Duration - expectedDuration
|
||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
@@ -494,7 +480,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
GoLog("[Tidal] ✓ ISRC match: '%s' (duration verified)\n", track.Title)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
// Duration mismatch, continue searching
|
|
||||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||||
expectedDuration, track.Duration)
|
expectedDuration, track.Duration)
|
||||||
} else {
|
} else {
|
||||||
@@ -513,7 +498,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
return nil, fmt.Errorf("no tracks found for any search query")
|
return nil, fmt.Errorf("no tracks found for any search query")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 1: Match by ISRC (exact match) WITH title verification
|
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
GoLog("[Tidal] Looking for ISRC match: %s\n", spotifyISRC)
|
||||||
var isrcMatches []*TidalTrack
|
var isrcMatches []*TidalTrack
|
||||||
@@ -525,7 +509,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(isrcMatches) > 0 {
|
if len(isrcMatches) > 0 {
|
||||||
// Verify duration first (most important check)
|
|
||||||
if expectedDuration > 0 {
|
if expectedDuration > 0 {
|
||||||
var durationVerifiedMatches []*TidalTrack
|
var durationVerifiedMatches []*TidalTrack
|
||||||
for _, track := range isrcMatches {
|
for _, track := range isrcMatches {
|
||||||
@@ -533,37 +516,31 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
if durationDiff < 0 {
|
if durationDiff < 0 {
|
||||||
durationDiff = -durationDiff
|
durationDiff = -durationDiff
|
||||||
}
|
}
|
||||||
// Allow 3 seconds tolerance for duration (same as PC version)
|
|
||||||
if durationDiff <= 3 {
|
if durationDiff <= 3 {
|
||||||
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
durationVerifiedMatches = append(durationVerifiedMatches, track)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(durationVerifiedMatches) > 0 {
|
if len(durationVerifiedMatches) > 0 {
|
||||||
// Return first duration-verified match
|
|
||||||
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
GoLog("[Tidal] ✓ ISRC match with duration verification: '%s' (expected %ds, found %ds)\n",
|
||||||
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
durationVerifiedMatches[0].Title, expectedDuration, durationVerifiedMatches[0].Duration)
|
||||||
return durationVerifiedMatches[0], nil
|
return durationVerifiedMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ISRC matches but duration doesn't - this is likely wrong version
|
|
||||||
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
GoLog("[Tidal] WARNING: ISRC %s found but duration mismatch. Expected=%ds, Found=%ds. Rejecting.\n",
|
||||||
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
spotifyISRC, expectedDuration, isrcMatches[0].Duration)
|
||||||
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
return nil, fmt.Errorf("ISRC found but duration mismatch: expected %ds, found %ds (likely different version/edit)",
|
||||||
expectedDuration, isrcMatches[0].Duration)
|
expectedDuration, isrcMatches[0].Duration)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No duration to verify, just return first ISRC match
|
|
||||||
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
GoLog("[Tidal] ✓ ISRC match (no duration verification): '%s'\n", isrcMatches[0].Title)
|
||||||
return isrcMatches[0], nil
|
return isrcMatches[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If ISRC was provided but no match found, return error
|
|
||||||
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
GoLog("[Tidal] ✗ No ISRC match found for: %s\n", spotifyISRC)
|
||||||
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
return nil, fmt.Errorf("ISRC mismatch: no track found with ISRC %s on Tidal", spotifyISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Match by duration (within tolerance) + prefer best quality
|
|
||||||
if expectedDuration > 0 {
|
if expectedDuration > 0 {
|
||||||
tolerance := 3 // 3 seconds tolerance
|
tolerance := 3 // 3 seconds tolerance
|
||||||
var durationMatches []*TidalTrack
|
var durationMatches []*TidalTrack
|
||||||
@@ -580,7 +557,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(durationMatches) > 0 {
|
if len(durationMatches) > 0 {
|
||||||
// Find best quality among duration matches
|
|
||||||
bestMatch := durationMatches[0]
|
bestMatch := durationMatches[0]
|
||||||
for _, track := range durationMatches {
|
for _, track := range durationMatches {
|
||||||
for _, tag := range track.MediaMetadata.Tags {
|
for _, tag := range track.MediaMetadata.Tags {
|
||||||
@@ -596,7 +572,6 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Just take the best quality from first results
|
|
||||||
bestMatch := &allTracks[0]
|
bestMatch := &allTracks[0]
|
||||||
for i := range allTracks {
|
for i := range allTracks {
|
||||||
track := &allTracks[i]
|
track := &allTracks[i]
|
||||||
@@ -660,12 +635,10 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
resultChan := make(chan tidalAPIResult, len(apis))
|
resultChan := make(chan tidalAPIResult, len(apis))
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Start all requests in parallel
|
|
||||||
for _, apiURL := range apis {
|
for _, apiURL := range apis {
|
||||||
go func(api string) {
|
go func(api string) {
|
||||||
reqStart := time.Now()
|
reqStart := time.Now()
|
||||||
|
|
||||||
// Create client with timeout for parallel requests
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 15 * time.Second,
|
Timeout: 15 * time.Second,
|
||||||
}
|
}
|
||||||
@@ -696,7 +669,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try v2 format first (object with manifest)
|
|
||||||
var v2Response TidalAPIResponseV2
|
var v2Response TidalAPIResponseV2
|
||||||
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||||
@@ -714,7 +686,6 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to v1 format (array with OriginalTrackUrl)
|
|
||||||
var v1Responses []struct {
|
var v1Responses []struct {
|
||||||
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||||
}
|
}
|
||||||
@@ -736,13 +707,11 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin
|
|||||||
}(apiURL)
|
}(apiURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect results - return first success
|
|
||||||
var errors []string
|
var errors []string
|
||||||
|
|
||||||
for i := 0; i < len(apis); i++ {
|
for i := 0; i < len(apis); i++ {
|
||||||
result := <-resultChan
|
result := <-resultChan
|
||||||
if result.err == nil {
|
if result.err == nil {
|
||||||
// First success - use this one
|
|
||||||
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
GoLog("[Tidal] [Parallel] ✓ Got response from %s (%d-bit/%dHz) in %v\n",
|
||||||
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
result.apiURL, result.info.BitDepth, result.info.SampleRate, result.duration)
|
||||||
|
|
||||||
@@ -775,7 +744,6 @@ func (t *TidalDownloader) GetDownloadURL(trackID int64, quality string) (TidalDo
|
|||||||
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
return TidalDownloadInfo{}, fmt.Errorf("no API URL configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use parallel approach - request from all APIs simultaneously
|
|
||||||
_, info, err := getDownloadURLParallel(apis, trackID, quality)
|
_, info, err := getDownloadURLParallel(apis, trackID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadInfo{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -793,16 +761,13 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
manifestStr := string(manifestBytes)
|
manifestStr := string(manifestBytes)
|
||||||
|
|
||||||
// Debug: log first 500 chars of manifest for debugging
|
|
||||||
manifestPreview := manifestStr
|
manifestPreview := manifestStr
|
||||||
if len(manifestPreview) > 500 {
|
if len(manifestPreview) > 500 {
|
||||||
manifestPreview = manifestPreview[:500] + "..."
|
manifestPreview = manifestPreview[:500] + "..."
|
||||||
}
|
}
|
||||||
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
|
GoLog("[Tidal] Manifest content: %s\n", manifestPreview)
|
||||||
|
|
||||||
// Check if it's BTS format (JSON) or DASH format (XML)
|
|
||||||
if strings.HasPrefix(manifestStr, "{") {
|
if strings.HasPrefix(manifestStr, "{") {
|
||||||
// BTS format - JSON with direct URLs
|
|
||||||
var btsManifest TidalBTSManifest
|
var btsManifest TidalBTSManifest
|
||||||
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
if err := json.Unmarshal(manifestBytes, &btsManifest); err != nil {
|
||||||
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
return "", "", nil, fmt.Errorf("failed to parse BTS manifest: %w", err)
|
||||||
@@ -815,7 +780,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
return btsManifest.URLs[0], "", nil, nil
|
return btsManifest.URLs[0], "", nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DASH format - XML with segments
|
|
||||||
var mpd MPD
|
var mpd MPD
|
||||||
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
if err := xml.Unmarshal(manifestBytes, &mpd); err != nil {
|
||||||
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
return "", "", nil, fmt.Errorf("failed to parse manifest XML: %w", err)
|
||||||
@@ -826,7 +790,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
mediaTemplate := segTemplate.Media
|
mediaTemplate := segTemplate.Media
|
||||||
|
|
||||||
if initURL == "" || mediaTemplate == "" {
|
if initURL == "" || mediaTemplate == "" {
|
||||||
// Fallback: try regex extraction
|
|
||||||
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
initRe := regexp.MustCompile(`initialization="([^"]+)"`)
|
||||||
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
mediaRe := regexp.MustCompile(`media="([^"]+)"`)
|
||||||
|
|
||||||
@@ -842,11 +805,9 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
|
return "", "", nil, fmt.Errorf("no initialization URL found in manifest")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unescape HTML entities in URLs
|
|
||||||
initURL = strings.ReplaceAll(initURL, "&", "&")
|
initURL = strings.ReplaceAll(initURL, "&", "&")
|
||||||
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
mediaTemplate = strings.ReplaceAll(mediaTemplate, "&", "&")
|
||||||
|
|
||||||
// Calculate segment count from timeline
|
|
||||||
segmentCount := 0
|
segmentCount := 0
|
||||||
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
GoLog("[Tidal] XML parsed segments: %d entries in timeline\n", len(segTemplate.Timeline.Segments))
|
||||||
for i, seg := range segTemplate.Timeline.Segments {
|
for i, seg := range segTemplate.Timeline.Segments {
|
||||||
@@ -855,10 +816,8 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
}
|
}
|
||||||
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
|
GoLog("[Tidal] Segment count from XML: %d\n", segmentCount)
|
||||||
|
|
||||||
// If no segments found via XML, try regex
|
|
||||||
if segmentCount == 0 {
|
if segmentCount == 0 {
|
||||||
fmt.Println("[Tidal] No segments from XML, trying regex...")
|
fmt.Println("[Tidal] No segments from XML, trying regex...")
|
||||||
// Match <S d="..." /> or <S d="..." r="..." />
|
|
||||||
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
segRe := regexp.MustCompile(`<S\s+d="(\d+)"(?:\s+r="(\d+)")?`)
|
||||||
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
matches := segRe.FindAllStringSubmatch(manifestStr, -1)
|
||||||
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
|
GoLog("[Tidal] Regex found %d segment entries\n", len(matches))
|
||||||
@@ -875,7 +834,6 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
GoLog("[Tidal] Total segments from regex: %d\n", segmentCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate media URLs for each segment
|
|
||||||
for i := 1; i <= segmentCount; i++ {
|
for i := 1; i <= segmentCount; i++ {
|
||||||
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i))
|
||||||
mediaURLs = append(mediaURLs, mediaURL)
|
mediaURLs = append(mediaURLs, mediaURL)
|
||||||
@@ -886,29 +844,43 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with progress tracking
|
// DownloadFile downloads a file from URL with progress tracking
|
||||||
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Handle manifest-based download (DASH/BTS)
|
ctx := context.Background()
|
||||||
|
|
||||||
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
if strings.HasPrefix(downloadURL, "MANIFEST:") {
|
||||||
// Initialize progress tracking for manifest downloads
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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
|
// Initialize item progress for direct downloads
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := DoRequestWithUserAgent(t.client, req)
|
resp, err := DoRequestWithUserAgent(t.client, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -918,7 +890,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes if available
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -928,26 +899,24 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use buffered writer for better performance (256KB buffer)
|
|
||||||
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
bufWriter := bufio.NewWriterSize(out, 256*1024)
|
||||||
|
|
||||||
// Use item progress writer with buffered output
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
progressWriter := NewItemProgressWriter(bufWriter, itemID)
|
||||||
written, err = io.Copy(progressWriter, resp.Body)
|
written, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
// Fallback: direct copy without progress tracking
|
|
||||||
written, err = io.Copy(bufWriter, resp.Body)
|
written, err = io.Copy(bufWriter, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush buffer before checking for errors
|
|
||||||
flushErr := bufWriter.Flush()
|
flushErr := bufWriter.Flush()
|
||||||
closeErr := out.Close()
|
closeErr := out.Close()
|
||||||
|
|
||||||
// Check for any errors
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if flushErr != nil {
|
if flushErr != nil {
|
||||||
@@ -959,7 +928,6 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
@@ -968,7 +936,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return nil
|
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...")
|
fmt.Println("[Tidal] Parsing manifest...")
|
||||||
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
directURL, initURL, mediaURLs, err := parseManifest(manifestB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -982,12 +950,15 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
Timeout: 120 * time.Second,
|
Timeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a direct URL (BTS format), download directly with progress tracking
|
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))])
|
||||||
// Note: Progress tracking is initialized by the caller (DownloadFile)
|
// 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 {
|
if err != nil {
|
||||||
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
GoLog("[Tidal] BTS request creation failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to create request: %w", err)
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
@@ -995,6 +966,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] BTS download failed: %v\n", err)
|
GoLog("[Tidal] BTS download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download file: %w", err)
|
return fmt.Errorf("failed to download file: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1007,7 +981,6 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
GoLog("[Tidal] BTS response OK, Content-Length: %d\n", resp.ContentLength)
|
||||||
|
|
||||||
expectedSize := resp.ContentLength
|
expectedSize := resp.ContentLength
|
||||||
// Set total bytes for progress tracking
|
|
||||||
if expectedSize > 0 && itemID != "" {
|
if expectedSize > 0 && itemID != "" {
|
||||||
SetItemBytesTotal(itemID, expectedSize)
|
SetItemBytesTotal(itemID, expectedSize)
|
||||||
}
|
}
|
||||||
@@ -1017,7 +990,6 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
return fmt.Errorf("failed to create file: %w", err)
|
return fmt.Errorf("failed to create file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use item progress writer
|
|
||||||
var written int64
|
var written int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
@@ -1030,6 +1002,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
return fmt.Errorf("download interrupted: %w", err)
|
return fmt.Errorf("download interrupted: %w", err)
|
||||||
}
|
}
|
||||||
if closeErr != nil {
|
if closeErr != nil {
|
||||||
@@ -1037,7 +1012,6 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
return fmt.Errorf("failed to close file: %w", closeErr)
|
return fmt.Errorf("failed to close file: %w", closeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file size if Content-Length was provided
|
|
||||||
if expectedSize > 0 && written != expectedSize {
|
if expectedSize > 0 && written != expectedSize {
|
||||||
os.Remove(outputPath)
|
os.Remove(outputPath)
|
||||||
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
|
||||||
@@ -1046,26 +1020,35 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DASH format - download segments directly to M4A file (no temp file to avoid Android permission issues)
|
|
||||||
// On Android, we can't use ffmpeg, so we save as M4A directly
|
|
||||||
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
m4aPath := strings.TrimSuffix(outputPath, ".flac") + ".m4a"
|
||||||
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
GoLog("[Tidal] DASH format - downloading %d segments directly to: %s\n", len(mediaURLs), m4aPath)
|
||||||
|
|
||||||
// Note: Progress tracking is initialized by the caller (DownloadFile or downloadFromTidal)
|
|
||||||
// We just update progress here based on segment count
|
|
||||||
|
|
||||||
out, err := os.Create(m4aPath)
|
out, err := os.Create(m4aPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
GoLog("[Tidal] Failed to create M4A file: %v\n", err)
|
||||||
return fmt.Errorf("failed to create M4A file: %w", err)
|
return fmt.Errorf("failed to create M4A file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download initialization segment
|
|
||||||
GoLog("[Tidal] Downloading init segment...\n")
|
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 {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
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)
|
GoLog("[Tidal] Init segment download failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to download init segment: %w", err)
|
return fmt.Errorf("failed to download init segment: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1081,27 +1064,44 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
GoLog("[Tidal] Init segment write failed: %v\n", err)
|
||||||
return fmt.Errorf("failed to write init segment: %w", err)
|
return fmt.Errorf("failed to write init segment: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download media segments with progress
|
|
||||||
totalSegments := len(mediaURLs)
|
totalSegments := len(mediaURLs)
|
||||||
for i, mediaURL := range mediaURLs {
|
for i, mediaURL := range mediaURLs {
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
out.Close()
|
||||||
|
os.Remove(m4aPath)
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
|
|
||||||
if i%10 == 0 || i == totalSegments-1 {
|
if i%10 == 0 || i == totalSegments-1 {
|
||||||
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update progress based on segment count
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progress := float64(i+1) / float64(totalSegments)
|
progress := float64(i+1) / float64(totalSegments)
|
||||||
SetItemProgress(itemID, progress, 0, 0)
|
SetItemProgress(itemID, progress, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Get(mediaURL)
|
req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
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)
|
GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
return fmt.Errorf("failed to download segment %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
@@ -1117,6 +1117,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
out.Close()
|
out.Close()
|
||||||
os.Remove(m4aPath)
|
os.Remove(m4aPath)
|
||||||
|
if isDownloadCancelled(itemID) {
|
||||||
|
return ErrDownloadCancelled
|
||||||
|
}
|
||||||
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err)
|
||||||
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
return fmt.Errorf("failed to write segment %d: %w", i+1, err)
|
||||||
}
|
}
|
||||||
@@ -1446,7 +1449,6 @@ func isLatinScript(s string) bool {
|
|||||||
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
||||||
downloader := NewTidalDownloader()
|
downloader := NewTidalDownloader()
|
||||||
|
|
||||||
// Check for existing file first
|
|
||||||
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + existingFile}, nil
|
||||||
}
|
}
|
||||||
@@ -1457,8 +1459,24 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
var track *TidalTrack
|
var track *TidalTrack
|
||||||
var err error
|
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
|
// 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 {
|
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
|
||||||
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
GoLog("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
|
||||||
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
|
||||||
@@ -1498,7 +1516,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
var tidalURL string
|
var tidalURL string
|
||||||
var slErr error
|
var slErr error
|
||||||
|
|
||||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID)
|
||||||
@@ -1509,12 +1526,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if slErr == nil && tidalURL != "" {
|
if slErr == nil && tidalURL != "" {
|
||||||
// Extract track ID and get track info
|
|
||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
if idErr == nil {
|
if idErr == nil {
|
||||||
track, err = downloader.GetTrackInfoByID(trackID)
|
track, err = downloader.GetTrackInfoByID(trackID)
|
||||||
if track != nil {
|
if track != nil {
|
||||||
// Get artist name from track
|
|
||||||
tidalArtist := track.Artist.Name
|
tidalArtist := track.Artist.Name
|
||||||
if len(track.Artists) > 0 {
|
if len(track.Artists) > 0 {
|
||||||
var artistNames []string
|
var artistNames []string
|
||||||
@@ -1524,7 +1539,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
tidalArtist = strings.Join(artistNames, ", ")
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify artist matches (SongLink is already accurate, no title check needed)
|
|
||||||
if !artistsMatch(req.ArtistName, tidalArtist) {
|
if !artistsMatch(req.ArtistName, tidalArtist) {
|
||||||
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n",
|
||||||
req.ArtistName, tidalArtist)
|
req.ArtistName, tidalArtist)
|
||||||
@@ -1596,12 +1610,10 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
GoLog("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
|
||||||
|
|
||||||
// Cache the track ID for future use
|
|
||||||
if req.ISRC != "" {
|
if req.ISRC != "" {
|
||||||
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build filename
|
|
||||||
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
|
||||||
"title": req.TrackName,
|
"title": req.TrackName,
|
||||||
"artist": req.ArtistName,
|
"artist": req.ArtistName,
|
||||||
@@ -1613,7 +1625,6 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
filename = sanitizeFilename(filename) + ".flac"
|
filename = sanitizeFilename(filename) + ".flac"
|
||||||
outputPath := filepath.Join(req.OutputDir, filename)
|
outputPath := filepath.Join(req.OutputDir, filename)
|
||||||
|
|
||||||
// Check if file already exists (both FLAC and M4A)
|
|
||||||
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
|
||||||
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
return TidalDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
|
||||||
}
|
}
|
||||||
@@ -1629,14 +1640,12 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
os.Remove(tmpPath)
|
os.Remove(tmpPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine quality to use (default to LOSSLESS if not specified)
|
|
||||||
quality := req.Quality
|
quality := req.Quality
|
||||||
if quality == "" {
|
if quality == "" {
|
||||||
quality = "LOSSLESS"
|
quality = "LOSSLESS"
|
||||||
}
|
}
|
||||||
GoLog("[Tidal] Using quality: %s\n", quality)
|
GoLog("[Tidal] Using quality: %s\n", quality)
|
||||||
|
|
||||||
// Get download URL using parallel API requests
|
|
||||||
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
downloadInfo, err := downloader.GetDownloadURL(track.ID, quality)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
|
||||||
@@ -1657,6 +1666,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
req.TrackName,
|
req.TrackName,
|
||||||
req.ArtistName,
|
req.ArtistName,
|
||||||
req.EmbedLyrics,
|
req.EmbedLyrics,
|
||||||
|
int64(req.DurationMS),
|
||||||
)
|
)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -1670,6 +1680,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}())
|
}())
|
||||||
|
|
||||||
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
|
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)
|
GoLog("[Tidal] Download failed with error: %v\n", err)
|
||||||
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1678,18 +1691,13 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
// Wait for parallel operations to complete
|
// Wait for parallel operations to complete
|
||||||
<-parallelDone
|
<-parallelDone
|
||||||
|
|
||||||
// Set progress to 100% and status to finalizing (before embedding)
|
|
||||||
// This makes the UI show "Finalizing..." while embedding happens
|
|
||||||
if req.ItemID != "" {
|
if req.ItemID != "" {
|
||||||
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
SetItemProgress(req.ItemID, 1.0, 0, 0)
|
||||||
SetItemFinalizing(req.ItemID)
|
SetItemFinalizing(req.ItemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file was saved as M4A (DASH stream) instead of FLAC
|
|
||||||
// downloadFromManifest saves DASH streams as .m4a (m4aPath already defined above)
|
|
||||||
actualOutputPath := outputPath
|
actualOutputPath := outputPath
|
||||||
if _, err := os.Stat(m4aPath); err == nil {
|
if _, err := os.Stat(m4aPath); err == nil {
|
||||||
// File was saved as M4A, use that path
|
|
||||||
actualOutputPath = m4aPath
|
actualOutputPath = m4aPath
|
||||||
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
GoLog("[Tidal] File saved as M4A (DASH stream): %s\n", actualOutputPath)
|
||||||
} else if _, err := os.Stat(outputPath); err != nil {
|
} else if _, err := os.Stat(outputPath); err != nil {
|
||||||
@@ -1708,9 +1716,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
TotalTracks: req.TotalTracks,
|
TotalTracks: req.TotalTracks,
|
||||||
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
|
DiscNumber: track.VolumeNumber, // Use actual disc number from Tidal
|
||||||
ISRC: track.ISRC, // Use actual ISRC from Tidal
|
ISRC: track.ISRC, // Use actual ISRC from Tidal
|
||||||
|
Genre: req.Genre, // From Deezer album metadata
|
||||||
|
Label: req.Label, // From Deezer album metadata
|
||||||
|
Copyright: req.Copyright, // From Deezer album metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cover data from parallel fetch
|
|
||||||
var coverData []byte
|
var coverData []byte
|
||||||
if parallelResult != nil && parallelResult.CoverData != nil {
|
if parallelResult != nil && parallelResult.CoverData != nil {
|
||||||
coverData = parallelResult.CoverData
|
coverData = parallelResult.CoverData
|
||||||
|
|||||||
@@ -120,6 +120,12 @@ import Gobackend // Import Go framework
|
|||||||
let itemId = args["item_id"] as! String
|
let itemId = args["item_id"] as! String
|
||||||
GobackendClearItemProgress(itemId)
|
GobackendClearItemProgress(itemId)
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "cancelDownload":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let itemId = args["item_id"] as! String
|
||||||
|
GobackendCancelDownload(itemId)
|
||||||
|
return nil
|
||||||
|
|
||||||
case "setDownloadDirectory":
|
case "setDownloadDirectory":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
@@ -155,7 +161,8 @@ import Gobackend // Import Go framework
|
|||||||
let spotifyId = args["spotify_id"] as! String
|
let spotifyId = args["spotify_id"] as! String
|
||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, &error)
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendFetchLyrics(spotifyId, trackName, artistName, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -165,7 +172,8 @@ import Gobackend // Import Go framework
|
|||||||
let trackName = args["track_name"] as! String
|
let trackName = args["track_name"] as! String
|
||||||
let artistName = args["artist_name"] as! String
|
let artistName = args["artist_name"] as! String
|
||||||
let filePath = args["file_path"] as? String ?? ""
|
let filePath = args["file_path"] as? String ?? ""
|
||||||
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, &error)
|
let durationMs = args["duration_ms"] as? Int64 ?? 0
|
||||||
|
let response = GobackendGetLyricsLRC(spotifyId, trackName, artistName, filePath, durationMs, &error)
|
||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@@ -219,6 +227,13 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
case "getDeezerExtendedMetadata":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let trackId = args["track_id"] as! String
|
||||||
|
let response = GobackendGetDeezerExtendedMetadata(trackId, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "convertSpotifyToDeezer":
|
case "convertSpotifyToDeezer":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let resourceType = args["resource_type"] as! String
|
let resourceType = args["resource_type"] as! String
|
||||||
@@ -367,6 +382,14 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case "invokeExtensionAction":
|
||||||
|
let args = call.arguments as! [String: Any]
|
||||||
|
let extensionId = args["extension_id"] as! String
|
||||||
|
let actionName = args["action"] as! String
|
||||||
|
let response = GobackendInvokeExtensionActionJSON(extensionId, actionName, &error)
|
||||||
|
if let error = error { throw error }
|
||||||
|
return response
|
||||||
|
|
||||||
case "searchTracksWithExtensions":
|
case "searchTracksWithExtensions":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
let query = args["query"] as! String
|
let query = args["query"] as! String
|
||||||
@@ -503,6 +526,30 @@ import Gobackend // Import Go framework
|
|||||||
if let error = error { throw error }
|
if let error = error { throw error }
|
||||||
return response
|
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
|
// Extension Post-Processing API
|
||||||
case "runPostProcessing":
|
case "runPostProcessing":
|
||||||
let args = call.arguments as! [String: Any]
|
let args = call.arguments as! [String: Any]
|
||||||
|
|||||||
@@ -4,6 +4,23 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<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>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>SpotiFLAC</string>
|
<string>SpotiFLAC</string>
|
||||||
<key>CFBundleExecutable</key>
|
<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,13 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotiflac_android/screens/main_shell.dart';
|
import 'package:spotiflac_android/screens/main_shell.dart';
|
||||||
import 'package:spotiflac_android/screens/setup_screen.dart';
|
import 'package:spotiflac_android/screens/setup_screen.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||||
|
|
||||||
final _routerProvider = Provider<GoRouter>((ref) {
|
final _routerProvider = Provider<GoRouter>((ref) {
|
||||||
// Only watch isFirstLaunch to prevent router rebuild on other settings changes
|
|
||||||
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
final isFirstLaunch = ref.watch(settingsProvider.select((s) => s.isFirstLaunch));
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
@@ -31,6 +32,12 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final router = ref.watch(_routerProvider);
|
final router = ref.watch(_routerProvider);
|
||||||
|
final localeString = ref.watch(settingsProvider.select((s) => s.locale));
|
||||||
|
|
||||||
|
Locale? locale;
|
||||||
|
if (localeString != 'system') {
|
||||||
|
locale = Locale(localeString);
|
||||||
|
}
|
||||||
|
|
||||||
return DynamicColorWrapper(
|
return DynamicColorWrapper(
|
||||||
builder: (lightTheme, darkTheme, themeMode) {
|
builder: (lightTheme, darkTheme, themeMode) {
|
||||||
@@ -43,6 +50,14 @@ class SpotiFLACApp extends ConsumerWidget {
|
|||||||
themeAnimationDuration: const Duration(milliseconds: 300),
|
themeAnimationDuration: const Duration(milliseconds: 300),
|
||||||
themeAnimationCurve: Curves.easeInOut,
|
themeAnimationCurve: Curves.easeInOut,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
locale: locale,
|
||||||
|
localizationsDelegates: const [
|
||||||
|
AppLocalizations.delegate,
|
||||||
|
GlobalMaterialLocalizations.delegate,
|
||||||
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
GlobalCupertinoLocalizations.delegate,
|
||||||
|
],
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '3.0.0';
|
static const String version = '3.1.2';
|
||||||
static const String buildNumber = '57';
|
static const String buildNumber = '61';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,687 @@
|
|||||||
|
{
|
||||||
|
"@@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",
|
||||||
|
"qualityMp3": "MP3",
|
||||||
|
"qualityMp3Subtitle": "320kbps (konversi dari FLAC)",
|
||||||
|
"enableMp3Option": "Aktifkan Opsi MP3",
|
||||||
|
"enableMp3OptionSubtitleOn": "Opsi kualitas MP3 tersedia",
|
||||||
|
"enableMp3OptionSubtitleOff": "Unduh FLAC lalu konversi ke MP3 320kbps",
|
||||||
|
"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",
|
||||||
|
"downloadedAlbumDiscHeader": "Disc {discNumber}",
|
||||||
|
|
||||||
|
"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,30 @@
|
|||||||
|
// 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('ru'),
|
||||||
|
Locale('es', 'ES'),
|
||||||
|
Locale('id'),
|
||||||
|
Locale('pt', 'PT'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Set of locale codes for quick lookup.
|
||||||
|
const Set<String> filteredLocaleCodes = <String>{
|
||||||
|
'en',
|
||||||
|
'ru',
|
||||||
|
'es_ES',
|
||||||
|
'id',
|
||||||
|
'pt_PT',
|
||||||
|
};
|
||||||
@@ -11,10 +11,8 @@ import 'package:spotiflac_android/services/share_intent_service.dart';
|
|||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
// Initialize notification service
|
|
||||||
await NotificationService().initialize();
|
await NotificationService().initialize();
|
||||||
|
|
||||||
// Initialize share intent service
|
|
||||||
await ShareIntentService().initialize();
|
await ShareIntentService().initialize();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
@@ -48,11 +46,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
final extensionsDir = '${appDir.path}/extensions';
|
final extensionsDir = '${appDir.path}/extensions';
|
||||||
final dataDir = '${appDir.path}/extension_data';
|
final dataDir = '${appDir.path}/extension_data';
|
||||||
|
|
||||||
// Create directories if needed
|
|
||||||
await Directory(extensionsDir).create(recursive: true);
|
await Directory(extensionsDir).create(recursive: true);
|
||||||
await Directory(dataDir).create(recursive: true);
|
await Directory(dataDir).create(recursive: true);
|
||||||
|
|
||||||
// Initialize extension system
|
|
||||||
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
await ref.read(extensionProvider.notifier).initialize(extensionsDir, dataDir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to initialize extensions: $e');
|
debugPrint('Failed to initialize extensions: $e');
|
||||||
@@ -61,7 +57,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Eagerly initialize download history provider to load from storage
|
|
||||||
ref.watch(downloadHistoryProvider);
|
ref.watch(downloadHistoryProvider);
|
||||||
return widget.child;
|
return widget.child;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ class AppSettings {
|
|||||||
final bool useExtensionProviders; // Use extension providers for downloads when available
|
final bool useExtensionProviders; // Use extension providers for downloads when available
|
||||||
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
final String? searchProvider; // null/empty = default (Deezer/Spotify), otherwise extension ID
|
||||||
final bool separateSingles; // Separate singles/EPs into their own folder
|
final bool separateSingles; // Separate singles/EPs into their own folder
|
||||||
final String albumFolderStructure; // artist_album or album_only
|
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
|
||||||
final bool showExtensionStore; // Show Extension Store tab in navigation
|
final bool showExtensionStore; // Show Extension Store tab in navigation
|
||||||
|
final String locale; // App language: 'system', 'en', 'id', etc.
|
||||||
|
final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion)
|
||||||
|
|
||||||
const AppSettings({
|
const AppSettings({
|
||||||
this.defaultService = 'tidal',
|
this.defaultService = 'tidal',
|
||||||
@@ -58,6 +60,8 @@ class AppSettings {
|
|||||||
this.separateSingles = false, // Default: disabled
|
this.separateSingles = false, // Default: disabled
|
||||||
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
|
||||||
this.showExtensionStore = true, // Default: show store
|
this.showExtensionStore = true, // Default: show store
|
||||||
|
this.locale = 'system', // Default: follow system language
|
||||||
|
this.enableMp3Option = false, // Default: disabled
|
||||||
});
|
});
|
||||||
|
|
||||||
AppSettings copyWith({
|
AppSettings copyWith({
|
||||||
@@ -88,6 +92,8 @@ class AppSettings {
|
|||||||
bool? separateSingles,
|
bool? separateSingles,
|
||||||
String? albumFolderStructure,
|
String? albumFolderStructure,
|
||||||
bool? showExtensionStore,
|
bool? showExtensionStore,
|
||||||
|
String? locale,
|
||||||
|
bool? enableMp3Option,
|
||||||
}) {
|
}) {
|
||||||
return AppSettings(
|
return AppSettings(
|
||||||
defaultService: defaultService ?? this.defaultService,
|
defaultService: defaultService ?? this.defaultService,
|
||||||
@@ -116,6 +122,8 @@ class AppSettings {
|
|||||||
separateSingles: separateSingles ?? this.separateSingles,
|
separateSingles: separateSingles ?? this.separateSingles,
|
||||||
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
|
||||||
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
|
||||||
|
locale: locale ?? this.locale,
|
||||||
|
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||||||
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
|
||||||
searchProvider: json['searchProvider'] as String?,
|
searchProvider: json['searchProvider'] as String?,
|
||||||
separateSingles: json['separateSingles'] as bool? ?? false,
|
separateSingles: json['separateSingles'] as bool? ?? false,
|
||||||
albumFolderStructure: json['albumFolderStructure'] as String? ?? 'artist_album',
|
albumFolderStructure:
|
||||||
|
json['albumFolderStructure'] as String? ?? 'artist_album',
|
||||||
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
|
||||||
|
locale: json['locale'] as String? ?? 'system',
|
||||||
|
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||||
@@ -64,4 +67,6 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||||||
'separateSingles': instance.separateSingles,
|
'separateSingles': instance.separateSingles,
|
||||||
'albumFolderStructure': instance.albumFolderStructure,
|
'albumFolderStructure': instance.albumFolderStructure,
|
||||||
'showExtensionStore': instance.showExtensionStore,
|
'showExtensionStore': instance.showExtensionStore,
|
||||||
|
'locale': instance.locale,
|
||||||
|
'enableMp3Option': instance.enableMp3Option,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class Track {
|
|||||||
final ServiceAvailability? availability;
|
final ServiceAvailability? availability;
|
||||||
final String? source; // Extension ID that provided this track (null for built-in sources)
|
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? albumType; // album, single, ep, compilation (from metadata API)
|
||||||
|
final String? itemType; // track, album, playlist - for extension search results
|
||||||
|
|
||||||
const Track({
|
const Track({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -37,10 +38,23 @@ class Track {
|
|||||||
this.availability,
|
this.availability,
|
||||||
this.source,
|
this.source,
|
||||||
this.albumType,
|
this.albumType,
|
||||||
|
this.itemType,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Check if this track is a single (based on album_type metadata)
|
/// Check if this track is a single (based on album_type metadata)
|
||||||
bool get isSingle => albumType == 'single' || albumType == 'ep';
|
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);
|
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
|
||||||
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
Map<String, dynamic> toJson() => _$TrackToJson(this);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ Track _$TrackFromJson(Map<String, dynamic> json) => Track(
|
|||||||
),
|
),
|
||||||
source: json['source'] as String?,
|
source: json['source'] as String?,
|
||||||
albumType: json['albumType'] as String?,
|
albumType: json['albumType'] as String?,
|
||||||
|
itemType: json['itemType'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
||||||
@@ -44,6 +45,7 @@ Map<String, dynamic> _$TrackToJson(Track instance) => <String, dynamic>{
|
|||||||
'availability': instance.availability,
|
'availability': instance.availability,
|
||||||
'source': instance.source,
|
'source': instance.source,
|
||||||
'albumType': instance.albumType,
|
'albumType': instance.albumType,
|
||||||
|
'itemType': instance.itemType,
|
||||||
};
|
};
|
||||||
|
|
||||||
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
ServiceAvailability _$ServiceAvailabilityFromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -175,12 +175,10 @@ class SearchBehavior {
|
|||||||
/// Get thumbnail size based on configuration
|
/// Get thumbnail size based on configuration
|
||||||
/// Returns (width, height) tuple
|
/// Returns (width, height) tuple
|
||||||
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
(double, double) getThumbnailSize({double defaultSize = 56}) {
|
||||||
// If custom dimensions specified, use them
|
|
||||||
if (thumbnailWidth != null && thumbnailHeight != null) {
|
if (thumbnailWidth != null && thumbnailHeight != null) {
|
||||||
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
return (thumbnailWidth!.toDouble(), thumbnailHeight!.toDouble());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise use ratio presets
|
|
||||||
switch (thumbnailRatio) {
|
switch (thumbnailRatio) {
|
||||||
case 'wide': // 16:9 - YouTube style
|
case 'wide': // 16:9 - YouTube style
|
||||||
return (defaultSize * 16 / 9, defaultSize);
|
return (defaultSize * 16 / 9, defaultSize);
|
||||||
@@ -357,11 +355,12 @@ class QualitySpecificSetting {
|
|||||||
class ExtensionSetting {
|
class ExtensionSetting {
|
||||||
final String key;
|
final String key;
|
||||||
final String label;
|
final String label;
|
||||||
final String type; // 'string', 'number', 'boolean', 'select'
|
final String type; // 'string', 'number', 'boolean', 'select', 'button'
|
||||||
final dynamic defaultValue;
|
final dynamic defaultValue;
|
||||||
final String? description;
|
final String? description;
|
||||||
final List<String>? options; // For select type
|
final List<String>? options; // For select type
|
||||||
final bool required;
|
final bool required;
|
||||||
|
final String? action; // For button type: JS function name to call
|
||||||
|
|
||||||
const ExtensionSetting({
|
const ExtensionSetting({
|
||||||
required this.key,
|
required this.key,
|
||||||
@@ -371,6 +370,7 @@ class ExtensionSetting {
|
|||||||
this.description,
|
this.description,
|
||||||
this.options,
|
this.options,
|
||||||
this.required = false,
|
this.required = false,
|
||||||
|
this.action,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
|
factory ExtensionSetting.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -382,6 +382,7 @@ class ExtensionSetting {
|
|||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
options: (json['options'] as List<dynamic>?)?.cast<String>(),
|
||||||
required: json['required'] as bool? ?? false,
|
required: json['required'] as bool? ?? false,
|
||||||
|
action: json['action'] as String?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,10 +559,8 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
await PlatformBridge.setExtensionEnabled(extensionId, enabled);
|
||||||
_log.d('Set extension $extensionId enabled: $enabled');
|
_log.d('Set extension $extensionId enabled: $enabled');
|
||||||
|
|
||||||
// Get extension info before updating state
|
|
||||||
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
final ext = state.extensions.where((e) => e.id == extensionId).firstOrNull;
|
||||||
|
|
||||||
// Update local state
|
|
||||||
final extensions = state.extensions.map((e) {
|
final extensions = state.extensions.map((e) {
|
||||||
if (e.id == extensionId) {
|
if (e.id == extensionId) {
|
||||||
return e.copyWith(enabled: enabled);
|
return e.copyWith(enabled: enabled);
|
||||||
@@ -571,18 +570,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
|
|||||||
|
|
||||||
state = state.copyWith(extensions: extensions);
|
state = state.copyWith(extensions: extensions);
|
||||||
|
|
||||||
// If disabling an extension, reset related settings
|
|
||||||
if (!enabled && ext != null) {
|
if (!enabled && ext != null) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
// If this extension was the search provider, clear it and reset to Deezer
|
|
||||||
if (settings.searchProvider == extensionId) {
|
if (settings.searchProvider == extensionId) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
_log.d('Cleared search provider and reset to Deezer because extension $extensionId was disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this extension was the default download service, reset to Tidal
|
|
||||||
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
|
||||||
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
ref.read(settingsProvider.notifier).setDefaultService('tidal');
|
||||||
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
_log.d('Reset default service to Tidal because extension $extensionId was disabled');
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
const _recentAccessKey = 'recent_access_history';
|
||||||
|
const _hiddenDownloadsKey = 'hidden_downloads_in_recents';
|
||||||
|
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 Set<String> hiddenDownloadIds; // IDs of downloads hidden from recents
|
||||||
|
final bool isLoaded;
|
||||||
|
|
||||||
|
const RecentAccessState({
|
||||||
|
this.items = const [],
|
||||||
|
this.hiddenDownloadIds = const {},
|
||||||
|
this.isLoaded = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
RecentAccessState copyWith({
|
||||||
|
List<RecentAccessItem>? items,
|
||||||
|
Set<String>? hiddenDownloadIds,
|
||||||
|
bool? isLoaded,
|
||||||
|
}) {
|
||||||
|
return RecentAccessState(
|
||||||
|
items: items ?? this.items,
|
||||||
|
hiddenDownloadIds: hiddenDownloadIds ?? this.hiddenDownloadIds,
|
||||||
|
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);
|
||||||
|
final hiddenJson = prefs.getStringList(_hiddenDownloadsKey);
|
||||||
|
|
||||||
|
List<RecentAccessItem> items = [];
|
||||||
|
Set<String> hiddenIds = {};
|
||||||
|
|
||||||
|
if (json != null) {
|
||||||
|
try {
|
||||||
|
final List<dynamic> decoded = jsonDecode(json);
|
||||||
|
items = decoded
|
||||||
|
.map((e) => RecentAccessItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hiddenJson != null) {
|
||||||
|
hiddenIds = hiddenJson.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(items: items, hiddenDownloadIds: hiddenIds, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveHiddenDownloads() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_hiddenDownloadsKey, state.hiddenDownloadIds.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
final updatedItems = state.items
|
||||||
|
.where((e) => e.uniqueKey != item.uniqueKey)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
updatedItems.insert(0, item);
|
||||||
|
|
||||||
|
if (updatedItems.length > _maxRecentItems) {
|
||||||
|
updatedItems.removeRange(_maxRecentItems, updatedItems.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(items: updatedItems);
|
||||||
|
_saveHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hide a download item from recents (without deleting the actual download)
|
||||||
|
void hideDownloadFromRecents(String downloadId) {
|
||||||
|
final updatedHidden = {...state.hiddenDownloadIds, downloadId};
|
||||||
|
state = state.copyWith(hiddenDownloadIds: updatedHidden);
|
||||||
|
_saveHiddenDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a download is hidden from recents
|
||||||
|
bool isDownloadHidden(String downloadId) {
|
||||||
|
return state.hiddenDownloadIds.contains(downloadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all history
|
||||||
|
void clearHistory() {
|
||||||
|
state = state.copyWith(items: []);
|
||||||
|
_saveHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear hidden downloads (show all again)
|
||||||
|
void clearHiddenDownloads() {
|
||||||
|
state = state.copyWith(hiddenDownloadIds: {});
|
||||||
|
_saveHiddenDownloads();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider instance
|
||||||
|
final recentAccessProvider = NotifierProvider<RecentAccessNotifier, RecentAccessState>(
|
||||||
|
RecentAccessNotifier.new,
|
||||||
|
);
|
||||||
@@ -22,13 +22,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
if (json != null) {
|
if (json != null) {
|
||||||
state = AppSettings.fromJson(jsonDecode(json));
|
state = AppSettings.fromJson(jsonDecode(json));
|
||||||
|
|
||||||
// Run migrations if needed
|
|
||||||
await _runMigrations(prefs);
|
await _runMigrations(prefs);
|
||||||
|
|
||||||
// Apply Spotify credentials to Go backend on load
|
|
||||||
_applySpotifyCredentials();
|
_applySpotifyCredentials();
|
||||||
|
|
||||||
// Sync logging state
|
|
||||||
LogBuffer.loggingEnabled = state.enableLogging;
|
LogBuffer.loggingEnabled = state.enableLogging;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,16 +35,12 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
final lastMigration = prefs.getInt(_migrationVersionKey) ?? 0;
|
||||||
|
|
||||||
if (lastMigration < 1) {
|
if (lastMigration < 1) {
|
||||||
// Migration 1: Set metadataSource to 'deezer' for existing users
|
|
||||||
// Only apply if user hasn't enabled custom Spotify credentials
|
|
||||||
// (users with custom credentials likely prefer Spotify)
|
|
||||||
if (!state.useCustomSpotifyCredentials) {
|
if (!state.useCustomSpotifyCredentials) {
|
||||||
state = state.copyWith(metadataSource: 'deezer');
|
state = state.copyWith(metadataSource: 'deezer');
|
||||||
await _saveSettings();
|
await _saveSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current migration version
|
|
||||||
if (lastMigration < _currentMigrationVersion) {
|
if (lastMigration < _currentMigrationVersion) {
|
||||||
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
|
||||||
}
|
}
|
||||||
@@ -60,7 +53,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
|
|
||||||
/// Apply current Spotify credentials to Go backend
|
/// Apply current Spotify credentials to Go backend
|
||||||
Future<void> _applySpotifyCredentials() async {
|
Future<void> _applySpotifyCredentials() async {
|
||||||
// Only apply if both fields are set
|
|
||||||
if (state.spotifyClientId.isNotEmpty &&
|
if (state.spotifyClientId.isNotEmpty &&
|
||||||
state.spotifyClientSecret.isNotEmpty) {
|
state.spotifyClientSecret.isNotEmpty) {
|
||||||
await PlatformBridge.setSpotifyCredentials(
|
await PlatformBridge.setSpotifyCredentials(
|
||||||
@@ -68,8 +60,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state.spotifyClientSecret,
|
state.spotifyClientSecret,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Note: If credentials are empty, Spotify API will return error
|
|
||||||
// User should use Deezer as metadata source instead
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDefaultService(String service) {
|
void setDefaultService(String service) {
|
||||||
@@ -113,7 +103,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setConcurrentDownloads(int count) {
|
void setConcurrentDownloads(int count) {
|
||||||
// Clamp between 1 and 3
|
|
||||||
final clamped = count.clamp(1, 3);
|
final clamped = count.clamp(1, 3);
|
||||||
state = state.copyWith(concurrentDownloads: clamped);
|
state = state.copyWith(concurrentDownloads: clamped);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
@@ -207,7 +196,6 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
void setEnableLogging(bool enabled) {
|
void setEnableLogging(bool enabled) {
|
||||||
state = state.copyWith(enableLogging: enabled);
|
state = state.copyWith(enableLogging: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
// Sync logging state to LogBuffer
|
|
||||||
LogBuffer.loggingEnabled = enabled;
|
LogBuffer.loggingEnabled = enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +218,20 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||||||
state = state.copyWith(showExtensionStore: enabled);
|
state = state.copyWith(showExtensionStore: enabled);
|
||||||
_saveSettings();
|
_saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void setLocale(String locale) {
|
||||||
|
state = state.copyWith(locale: locale);
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setEnableMp3Option(bool enabled) {
|
||||||
|
state = state.copyWith(enableMp3Option: enabled);
|
||||||
|
// If MP3 is disabled and current quality is MP3, reset to LOSSLESS
|
||||||
|
if (!enabled && state.audioQuality == 'MP3') {
|
||||||
|
state = state.copyWith(audioQuality: 'LOSSLESS');
|
||||||
|
}
|
||||||
|
_saveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
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/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
final _log = AppLogger('StoreProvider');
|
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
|
/// Extension categories
|
||||||
class StoreCategory {
|
class StoreCategory {
|
||||||
static const String metadata = 'metadata';
|
static const String metadata = 'metadata';
|
||||||
@@ -91,6 +110,12 @@ class StoreExtension {
|
|||||||
hasUpdate: json['has_update'] as bool? ?? false,
|
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
|
/// State for extension store
|
||||||
@@ -161,6 +186,11 @@ class StoreState {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Count of extensions with updates available
|
||||||
|
int get updatesAvailableCount {
|
||||||
|
return extensions.where((e) => e.hasUpdate).length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provider for managing extension store
|
/// Provider for managing extension store
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ class TrackState {
|
|||||||
final String? artistId;
|
final String? artistId;
|
||||||
final String? artistName;
|
final String? artistName;
|
||||||
final String? coverUrl;
|
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<ArtistAlbum>? artistAlbums; // For artist page
|
||||||
|
final List<Track>? artistTopTracks; // Artist's popular tracks
|
||||||
final List<SearchArtist>? searchArtists; // For search results
|
final List<SearchArtist>? searchArtists; // For search results
|
||||||
final bool hasSearchText; // For back button handling
|
final bool hasSearchText; // For back button handling
|
||||||
|
final bool isShowingRecentAccess; // For recent access mode
|
||||||
final String? searchExtensionId; // Extension ID used for current search results
|
final String? searchExtensionId; // Extension ID used for current search results
|
||||||
|
|
||||||
const TrackState({
|
const TrackState({
|
||||||
@@ -32,9 +36,13 @@ class TrackState {
|
|||||||
this.artistId,
|
this.artistId,
|
||||||
this.artistName,
|
this.artistName,
|
||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
|
this.headerImageUrl,
|
||||||
|
this.monthlyListeners,
|
||||||
this.artistAlbums,
|
this.artistAlbums,
|
||||||
|
this.artistTopTracks,
|
||||||
this.searchArtists,
|
this.searchArtists,
|
||||||
this.hasSearchText = false,
|
this.hasSearchText = false,
|
||||||
|
this.isShowingRecentAccess = false,
|
||||||
this.searchExtensionId,
|
this.searchExtensionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,9 +58,13 @@ class TrackState {
|
|||||||
String? artistId,
|
String? artistId,
|
||||||
String? artistName,
|
String? artistName,
|
||||||
String? coverUrl,
|
String? coverUrl,
|
||||||
|
String? headerImageUrl,
|
||||||
|
int? monthlyListeners,
|
||||||
List<ArtistAlbum>? artistAlbums,
|
List<ArtistAlbum>? artistAlbums,
|
||||||
|
List<Track>? artistTopTracks,
|
||||||
List<SearchArtist>? searchArtists,
|
List<SearchArtist>? searchArtists,
|
||||||
bool? hasSearchText,
|
bool? hasSearchText,
|
||||||
|
bool? isShowingRecentAccess,
|
||||||
String? searchExtensionId,
|
String? searchExtensionId,
|
||||||
}) {
|
}) {
|
||||||
return TrackState(
|
return TrackState(
|
||||||
@@ -65,9 +77,13 @@ class TrackState {
|
|||||||
artistId: artistId ?? this.artistId,
|
artistId: artistId ?? this.artistId,
|
||||||
artistName: artistName ?? this.artistName,
|
artistName: artistName ?? this.artistName,
|
||||||
coverUrl: coverUrl ?? this.coverUrl,
|
coverUrl: coverUrl ?? this.coverUrl,
|
||||||
|
headerImageUrl: headerImageUrl ?? this.headerImageUrl,
|
||||||
|
monthlyListeners: monthlyListeners ?? this.monthlyListeners,
|
||||||
artistAlbums: artistAlbums ?? this.artistAlbums,
|
artistAlbums: artistAlbums ?? this.artistAlbums,
|
||||||
|
artistTopTracks: artistTopTracks ?? this.artistTopTracks,
|
||||||
searchArtists: searchArtists ?? this.searchArtists,
|
searchArtists: searchArtists ?? this.searchArtists,
|
||||||
hasSearchText: hasSearchText ?? this.hasSearchText,
|
hasSearchText: hasSearchText ?? this.hasSearchText,
|
||||||
|
isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess,
|
||||||
searchExtensionId: searchExtensionId,
|
searchExtensionId: searchExtensionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,6 +98,7 @@ class ArtistAlbum {
|
|||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final String albumType; // album, single, compilation
|
final String albumType; // album, single, compilation
|
||||||
final String artists;
|
final String artists;
|
||||||
|
final String? providerId; // Extension ID if from extension
|
||||||
|
|
||||||
const ArtistAlbum({
|
const ArtistAlbum({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -91,6 +108,7 @@ class ArtistAlbum {
|
|||||||
this.coverUrl,
|
this.coverUrl,
|
||||||
required this.albumType,
|
required this.albumType,
|
||||||
required this.artists,
|
required this.artists,
|
||||||
|
this.providerId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,14 +142,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||||
|
|
||||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve hasSearchText during fetch
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// First, check if any extension can handle this URL
|
|
||||||
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
final extensionHandler = await PlatformBridge.findURLHandler(url);
|
||||||
if (extensionHandler != null) {
|
if (extensionHandler != null) {
|
||||||
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
|
||||||
@@ -169,13 +184,20 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
final artistData = result['artist'] as Map<String, dynamic>;
|
final artistData = result['artist'] as Map<String, dynamic>;
|
||||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||||
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
|
final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||||
|
final topTracks = topTracksList.map((t) => _parseSearchTrack(t as Map<String, dynamic>, source: extensionId)).toList();
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: [],
|
tracks: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
artistId: artistData['id'] as String?,
|
artistId: artistData['id'] as String?,
|
||||||
artistName: artistData['name'] as String?,
|
artistName: artistData['name'] as String?,
|
||||||
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
coverUrl: artistData['image_url'] as String? ?? artistData['images'] as String?,
|
||||||
|
headerImageUrl: artistData['header_image'] as String?,
|
||||||
|
monthlyListeners: artistData['listeners'] as int?,
|
||||||
artistAlbums: albums,
|
artistAlbums: albums,
|
||||||
|
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
|
||||||
searchExtensionId: extensionId,
|
searchExtensionId: extensionId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -183,13 +205,11 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No extension handler found, try Spotify URL parsing
|
|
||||||
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
final parsed = await PlatformBridge.parseSpotifyUrl(url);
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||||
|
|
||||||
final type = parsed['type'] as String;
|
final type = parsed['type'] as String;
|
||||||
|
|
||||||
// Use the new fallback-enabled method
|
|
||||||
Map<String, dynamic> metadata;
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -199,7 +219,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[FetchURL] Metadata fetch success');
|
print('[FetchURL] Metadata fetch success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If fallback also fails, show error
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[FetchURL] Metadata fetch failed: $e');
|
print('[FetchURL] Metadata fetch failed: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -226,7 +245,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
albumName: albumInfo['name'] as String?,
|
albumName: albumInfo['name'] as String?,
|
||||||
coverUrl: albumInfo['images'] as String?,
|
coverUrl: albumInfo['images'] as String?,
|
||||||
);
|
);
|
||||||
// Pre-warm cache for album tracks in background
|
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'playlist') {
|
} else if (type == 'playlist') {
|
||||||
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
|
||||||
@@ -239,7 +257,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
playlistName: owner?['name'] as String?,
|
playlistName: owner?['name'] as String?,
|
||||||
coverUrl: owner?['images'] as String?,
|
coverUrl: owner?['images'] as String?,
|
||||||
);
|
);
|
||||||
// Pre-warm cache for playlist tracks in background
|
|
||||||
_preWarmCacheForTracks(tracks);
|
_preWarmCacheForTracks(tracks);
|
||||||
} else if (type == 'artist') {
|
} else if (type == 'artist') {
|
||||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
|
||||||
@@ -255,44 +272,44 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return;
|
||||||
// Preserve hasSearchText on error so user stays on search screen
|
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> search(String query, {String? metadataSource}) async {
|
Future<void> search(String query, {String? metadataSource}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve hasSearchText during search
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if extension providers should be used for search
|
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
final extensionState = ref.read(extensionProvider);
|
final extensionState = ref.read(extensionProvider);
|
||||||
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
final hasActiveMetadataExtensions = extensionState.extensions.any(
|
||||||
(e) => e.enabled && e.hasMetadataProvider,
|
(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';
|
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;
|
Map<String, dynamic> results;
|
||||||
List<Track> extensionTracks = [];
|
List<Track> extensionTracks = [];
|
||||||
|
|
||||||
// Try extension providers first if enabled
|
|
||||||
if (useExtensions) {
|
if (useExtensions) {
|
||||||
try {
|
try {
|
||||||
_log.d('Calling extension search API...');
|
_log.d('Calling extension search API...');
|
||||||
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
final extResults = await PlatformBridge.searchTracksWithExtensions(query, limit: 20);
|
||||||
_log.i('Extensions returned ${extResults.length} tracks');
|
_log.i('Extensions returned ${extResults.length} tracks');
|
||||||
|
|
||||||
// Parse extension results
|
|
||||||
for (final t in extResults) {
|
for (final t in extResults) {
|
||||||
try {
|
try {
|
||||||
extensionTracks.add(_parseSearchTrack(t));
|
extensionTracks.add(_parseSearchTrack(t));
|
||||||
@@ -305,7 +322,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also search with built-in providers
|
|
||||||
if (source == 'deezer') {
|
if (source == 'deezer') {
|
||||||
_log.d('Calling Deezer search API...');
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
@@ -326,13 +342,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||||
|
|
||||||
// Parse tracks with error handling per item
|
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
|
|
||||||
// Add extension tracks first (they have priority)
|
|
||||||
tracks.addAll(extensionTracks);
|
tracks.addAll(extensionTracks);
|
||||||
|
|
||||||
// Add built-in provider tracks, avoiding duplicates by ISRC
|
|
||||||
final existingIsrcs = extensionTracks
|
final existingIsrcs = extensionTracks
|
||||||
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
.where((t) => t.isrc != null && t.isrc!.isNotEmpty)
|
||||||
.map((t) => t.isrc!)
|
.map((t) => t.isrc!)
|
||||||
@@ -343,7 +356,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
try {
|
try {
|
||||||
if (t is Map<String, dynamic>) {
|
if (t is Map<String, dynamic>) {
|
||||||
final track = _parseSearchTrack(t);
|
final track = _parseSearchTrack(t);
|
||||||
// Skip if we already have this track from extensions
|
|
||||||
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
|
if (track.isrc != null && existingIsrcs.contains(track.isrc)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -356,7 +368,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse artists with error handling per item
|
|
||||||
final artists = <SearchArtist>[];
|
final artists = <SearchArtist>[];
|
||||||
for (int i = 0; i < artistList.length; i++) {
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
final a = artistList[i];
|
final a = artistList[i];
|
||||||
@@ -388,10 +399,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
/// Perform custom search using a specific extension
|
/// Perform custom search using a specific extension
|
||||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? options}) async {
|
||||||
// Increment request ID to cancel any pending requests
|
|
||||||
final requestId = ++_currentRequestId;
|
final requestId = ++_currentRequestId;
|
||||||
|
|
||||||
// Preserve hasSearchText during search
|
|
||||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -406,7 +415,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
|
|
||||||
_log.i('Custom search returned ${results.length} tracks');
|
_log.i('Custom search returned ${results.length} tracks');
|
||||||
|
|
||||||
// Parse tracks with error handling per item, setting source to extension ID
|
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
for (int i = 0; i < results.length; i++) {
|
for (int i = 0; i < results.length; i++) {
|
||||||
final t = results[i];
|
final t = results[i];
|
||||||
@@ -453,6 +461,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
trackNumber: track.trackNumber,
|
trackNumber: track.trackNumber,
|
||||||
discNumber: track.discNumber,
|
discNumber: track.discNumber,
|
||||||
releaseDate: track.releaseDate,
|
releaseDate: track.releaseDate,
|
||||||
|
albumType: track.albumType,
|
||||||
|
source: track.source,
|
||||||
availability: ServiceAvailability(
|
availability: ServiceAvailability(
|
||||||
tidal: availability['tidal'] as bool? ?? false,
|
tidal: availability['tidal'] as bool? ?? false,
|
||||||
qobuz: availability['qobuz'] as bool? ?? false,
|
qobuz: availability['qobuz'] as bool? ?? false,
|
||||||
@@ -467,7 +477,8 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
tracks[index] = updatedTrack;
|
tracks[index] = updatedTrack;
|
||||||
state = state.copyWith(tracks: tracks);
|
state = state.copyWith(tracks: tracks);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Silently fail availability check
|
// Silently ignore availability check errors
|
||||||
|
// This is a background operation that shouldn't disrupt the user
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +490,28 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
void setSearchText(bool hasText) {
|
void setSearchText(bool hasText) {
|
||||||
state = state.copyWith(hasSearchText: 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) {
|
Track _parseTrack(Map<String, dynamic> data) {
|
||||||
return Track(
|
return Track(
|
||||||
@@ -497,7 +530,6 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
Track _parseSearchTrack(Map<String, dynamic> data, {String? source}) {
|
||||||
// Handle duration_ms which might be int or double
|
|
||||||
int durationMs = 0;
|
int durationMs = 0;
|
||||||
final durationValue = data['duration_ms'];
|
final durationValue = data['duration_ms'];
|
||||||
if (durationValue is int) {
|
if (durationValue is int) {
|
||||||
@@ -506,13 +538,15 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
durationMs = durationValue.toInt();
|
durationMs = durationValue.toInt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final itemType = data['item_type']?.toString();
|
||||||
|
|
||||||
return Track(
|
return Track(
|
||||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||||
name: (data['name'] ?? '').toString(),
|
name: (data['name'] ?? '').toString(),
|
||||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||||
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
albumName: (data['album_name'] ?? data['album'] ?? '').toString(),
|
||||||
albumArtist: data['album_artist']?.toString(),
|
albumArtist: data['album_artist']?.toString(),
|
||||||
coverUrl: data['images']?.toString(),
|
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||||
isrc: data['isrc']?.toString(),
|
isrc: data['isrc']?.toString(),
|
||||||
duration: (durationMs / 1000).round(),
|
duration: (durationMs / 1000).round(),
|
||||||
trackNumber: data['track_number'] as int?,
|
trackNumber: data['track_number'] as int?,
|
||||||
@@ -520,6 +554,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
releaseDate: data['release_date']?.toString(),
|
releaseDate: data['release_date']?.toString(),
|
||||||
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
source: source ?? data['source']?.toString() ?? data['provider_id']?.toString(),
|
||||||
albumType: data['album_type']?.toString(),
|
albumType: data['album_type']?.toString(),
|
||||||
|
itemType: itemType,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,9 +564,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
name: data['name'] as String? ?? '',
|
name: data['name'] as String? ?? '',
|
||||||
releaseDate: data['release_date'] as String? ?? '',
|
releaseDate: data['release_date'] as String? ?? '',
|
||||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
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',
|
albumType: data['album_type'] as String? ?? 'album',
|
||||||
artists: data['artists'] as String? ?? '',
|
artists: data['artists'] as String? ?? '',
|
||||||
|
providerId: data['provider_id']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,23 +584,18 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
/// Pre-warm track ID cache for faster downloads
|
/// Pre-warm track ID cache for faster downloads
|
||||||
/// Runs in background, doesn't block UI
|
/// Runs in background, doesn't block UI
|
||||||
void _preWarmCacheForTracks(List<Track> tracks) {
|
void _preWarmCacheForTracks(List<Track> tracks) {
|
||||||
// Only pre-warm if we have tracks with ISRC
|
|
||||||
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
|
||||||
if (tracksWithIsrc.isEmpty) return;
|
if (tracksWithIsrc.isEmpty) return;
|
||||||
|
|
||||||
// Build request list for Go backend
|
|
||||||
final cacheRequests = tracksWithIsrc.map((t) => {
|
final cacheRequests = tracksWithIsrc.map((t) => {
|
||||||
'isrc': t.isrc!,
|
'isrc': t.isrc!,
|
||||||
'track_name': t.name,
|
'track_name': t.name,
|
||||||
'artist_name': t.artistName,
|
'artist_name': t.artistName,
|
||||||
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
|
||||||
'service': 'tidal', // Default to tidal for pre-warming
|
'service': 'tidal',
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Fire and forget - runs in background
|
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {});
|
||||||
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
|
|
||||||
// Silently ignore errors - this is just an optimization
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/recent_access_provider.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
|
||||||
@@ -58,15 +61,67 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
List<Track>? _tracks;
|
List<Track>? _tracks;
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
Color? _dominantColor;
|
||||||
|
bool _showTitleInAppBar = false;
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Priority: widget.tracks > cache > fetch
|
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
_tracks = widget.tracks ?? _AlbumCache.get(widget.albumId);
|
||||||
if (_tracks == null) {
|
if (_tracks == null) {
|
||||||
_fetchTracks();
|
_fetchTracks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_extractDominantColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
// Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top)
|
||||||
|
final shouldShow = _scrollController.offset > 280;
|
||||||
|
if (shouldShow != _showTitleInAppBar) {
|
||||||
|
setState(() => _showTitleInAppBar = shouldShow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _extractDominantColor() async {
|
||||||
|
if (widget.coverUrl == null) return;
|
||||||
|
try {
|
||||||
|
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||||
|
CachedNetworkImageProvider(widget.coverUrl!),
|
||||||
|
maximumColorCount: 16,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_dominantColor = paletteGenerator.dominantColor?.color ??
|
||||||
|
paletteGenerator.vibrantColor?.color ??
|
||||||
|
paletteGenerator.mutedColor?.color;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore palette extraction errors
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchTracks() async {
|
Future<void> _fetchTracks() async {
|
||||||
@@ -74,14 +129,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
try {
|
try {
|
||||||
Map<String, dynamic> metadata;
|
Map<String, dynamic> metadata;
|
||||||
|
|
||||||
// Check if this is a Deezer album ID (format: "deezer:123456")
|
|
||||||
if (widget.albumId.startsWith('deezer:')) {
|
if (widget.albumId.startsWith('deezer:')) {
|
||||||
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
print('[AlbumScreen] Fetching from Deezer: $deezerAlbumId');
|
||||||
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
metadata = await PlatformBridge.getDeezerMetadata('album', deezerAlbumId);
|
||||||
} else {
|
} else {
|
||||||
// Spotify album - use fallback method
|
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
print('[AlbumScreen] Fetching from Spotify with fallback: ${widget.albumId}');
|
||||||
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
final url = 'https://open.spotify.com/album/${widget.albumId}';
|
||||||
@@ -91,7 +144,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
final trackList = metadata['track_list'] as List<dynamic>;
|
final trackList = metadata['track_list'] as List<dynamic>;
|
||||||
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
|
||||||
|
|
||||||
// Store in cache
|
|
||||||
_AlbumCache.set(widget.albumId, tracks);
|
_AlbumCache.set(widget.albumId, tracks);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -133,6 +185,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme),
|
_buildAppBar(context, colorScheme),
|
||||||
_buildInfoCard(context, colorScheme),
|
_buildInfoCard(context, colorScheme),
|
||||||
@@ -157,74 +210,106 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||||
|
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 320,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
title: AnimatedOpacity(
|
||||||
background: Stack(
|
duration: const Duration(milliseconds: 200),
|
||||||
fit: StackFit.expand,
|
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||||
children: [
|
child: Text(
|
||||||
if (widget.coverUrl != null)
|
widget.albumName,
|
||||||
CachedNetworkImage(
|
style: TextStyle(
|
||||||
imageUrl: widget.coverUrl!,
|
color: colorScheme.onSurface,
|
||||||
fit: BoxFit.cover,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
fontSize: 16,
|
||||||
colorBlendMode: BlendMode.darken,
|
),
|
||||||
memCacheWidth: 600,
|
maxLines: 1,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
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],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 60),
|
|
||||||
child: Container(
|
|
||||||
width: 140,
|
|
||||||
height: 140,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: const Offset(0, 10),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: widget.coverUrl != null
|
|
||||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
|
||||||
: Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
),
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||||
|
final showContent = collapseRatio > 0.3;
|
||||||
|
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
collapseMode: CollapseMode.none,
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Background with dominant color
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
bgColor,
|
||||||
|
bgColor.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.6, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Cover image centered - fade out when collapsing
|
||||||
|
AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
opacity: showContent ? 1.0 : 0.0,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 30,
|
||||||
|
offset: const Offset(0, 15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: (coverSize * 2).toInt(),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: Container(
|
icon: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
@@ -234,6 +319,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
final tracks = _tracks ?? [];
|
final tracks = _tracks ?? [];
|
||||||
|
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
|
||||||
|
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -250,7 +337,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
widget.albumName,
|
widget.albumName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
if (artistName != null && artistName.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
artistName,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
if (tracks.isNotEmpty)
|
if (tracks.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
@@ -260,7 +354,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
Icon(Icons.music_note, size: 14, color: colorScheme.onSecondaryContainer),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('${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 +363,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _downloadAll(context),
|
onPressed: () => _downloadAll(context),
|
||||||
icon: const Icon(Icons.download),
|
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))),
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -289,7 +383,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('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 +418,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
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 +438,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
artistName: widget.albumName,
|
artistName: widget.albumName,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
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 +469,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Rate Limited',
|
context.l10n.errorRateLimited,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -383,7 +477,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Too many requests. Please wait a moment and try again.',
|
context.l10n.errorRateLimitedMessage,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: colorScheme.onErrorContainer,
|
color: colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -398,7 +492,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default error display
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
color: colorScheme.errorContainer.withValues(alpha: 0.5),
|
||||||
@@ -428,12 +521,10 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Only watch the specific item for this track
|
|
||||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
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) {
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
return state.isDownloaded(track.id);
|
return state.isDownloaded(track.id);
|
||||||
}));
|
}));
|
||||||
@@ -444,7 +535,6 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
final progress = queueItem?.progress ?? 0.0;
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
// Show as downloaded if in queue completed OR in history
|
|
||||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -476,7 +566,7 @@ class _AlbumTrackItem extends ConsumerWidget {
|
|||||||
final fileExists = await File(historyItem.filePath).exists();
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
if (context.mounted) {
|
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;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:open_filex/open_filex.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/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
|
|
||||||
@@ -25,18 +28,74 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||||
// Multi-select state
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
|
Color? _dominantColor;
|
||||||
|
bool _showTitleInAppBar = false;
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_extractDominantColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
final shouldShow = _scrollController.offset > 280;
|
||||||
|
if (shouldShow != _showTitleInAppBar) {
|
||||||
|
setState(() => _showTitleInAppBar = shouldShow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _extractDominantColor() async {
|
||||||
|
if (widget.coverUrl == null || widget.coverUrl!.isEmpty) return;
|
||||||
|
|
||||||
|
// Only use network images for palette extraction
|
||||||
|
final isNetworkUrl = widget.coverUrl!.startsWith('http://') ||
|
||||||
|
widget.coverUrl!.startsWith('https://');
|
||||||
|
if (!isNetworkUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||||
|
CachedNetworkImageProvider(widget.coverUrl!),
|
||||||
|
maximumColorCount: 16,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_dominantColor = paletteGenerator.dominantColor?.color ??
|
||||||
|
paletteGenerator.vibrantColor?.color ??
|
||||||
|
paletteGenerator.mutedColor?.color;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore palette extraction errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get tracks for this album from history provider (reactive)
|
/// Get tracks for this album from history provider (reactive)
|
||||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||||
return allItems.where((item) {
|
return allItems.where((item) {
|
||||||
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
// Use albumArtist if available and not empty, otherwise artistName
|
||||||
|
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||||
|
? item.albumArtist!
|
||||||
|
: item.artistName;
|
||||||
|
final itemKey = '${item.albumName}|$itemArtist';
|
||||||
final albumKey = '${widget.albumName}|${widget.artistName}';
|
final albumKey = '${widget.albumName}|${widget.artistName}';
|
||||||
return itemKey == albumKey;
|
return itemKey == albumKey;
|
||||||
}).toList()
|
}).toList()
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
|
// Sort by disc number first, then by track number
|
||||||
|
final aDisc = a.discNumber ?? 1;
|
||||||
|
final bDisc = b.discNumber ?? 1;
|
||||||
|
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||||
final aNum = a.trackNumber ?? 999;
|
final aNum = a.trackNumber ?? 999;
|
||||||
final bNum = b.trackNumber ?? 999;
|
final bNum = b.trackNumber ?? 999;
|
||||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||||
@@ -44,6 +103,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get unique disc numbers from tracks (sorted)
|
||||||
|
List<int> _getDiscNumbers(List<DownloadHistoryItem> tracks) {
|
||||||
|
final discNumbers = tracks
|
||||||
|
.map((t) => t.discNumber ?? 1)
|
||||||
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
return discNumbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if album has multiple discs
|
||||||
|
bool _hasMultipleDiscs(List<DownloadHistoryItem> tracks) {
|
||||||
|
return _getDiscNumbers(tracks).length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get tracks for a specific disc
|
||||||
|
List<DownloadHistoryItem> _getTracksForDisc(List<DownloadHistoryItem> tracks, int discNumber) {
|
||||||
|
return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList();
|
||||||
|
}
|
||||||
|
|
||||||
void _enterSelectionMode(String itemId) {
|
void _enterSelectionMode(String itemId) {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -83,19 +162,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Delete Selected'),
|
title: Text(context.l10n.downloadedAlbumDeleteSelected),
|
||||||
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
|
content: Text(context.l10n.downloadedAlbumDeleteMessage(count)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
child: const Text('Delete'),
|
child: Text(context.l10n.dialogDelete),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -124,7 +203,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
|
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,11 +211,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
|
|
||||||
Future<void> _openFile(String filePath) async {
|
Future<void> _openFile(String filePath) async {
|
||||||
try {
|
try {
|
||||||
await OpenFilex.open(filePath);
|
final mimeType = audioMimeTypeForPath(filePath);
|
||||||
|
await OpenFilex.open(filePath, type: mimeType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Cannot open file: $e')),
|
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,19 +236,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||||
|
|
||||||
// Watch history and get tracks for this album (reactive!)
|
|
||||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
final tracks = _getAlbumTracks(allHistoryItems);
|
final tracks = _getAlbumTracks(allHistoryItems);
|
||||||
|
|
||||||
// Auto-pop if album has less than 2 tracks (no longer an "album")
|
// Show empty state if no tracks found
|
||||||
if (tracks.length < 2) {
|
if (tracks.isEmpty) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
return Scaffold(
|
||||||
if (mounted) Navigator.pop(context);
|
appBar: AppBar(
|
||||||
});
|
title: Text(widget.albumName),
|
||||||
return const SizedBox.shrink();
|
),
|
||||||
|
body: Center(
|
||||||
|
child: Text('No tracks found for this album'),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up selected IDs that no longer exist
|
|
||||||
final validIds = tracks.map((t) => t.id).toSet();
|
final validIds = tracks.map((t) => t.id).toSet();
|
||||||
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||||
if (_selectedIds.isEmpty && _isSelectionMode) {
|
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||||
@@ -188,6 +270,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
CustomScrollView(
|
CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme),
|
_buildAppBar(context, colorScheme),
|
||||||
_buildInfoCard(context, colorScheme, tracks),
|
_buildInfoCard(context, colorScheme, tracks),
|
||||||
@@ -197,7 +280,6 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom Selection Action Bar
|
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
@@ -213,69 +295,98 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||||
|
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 320,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
title: AnimatedOpacity(
|
||||||
background: Stack(
|
duration: const Duration(milliseconds: 200),
|
||||||
fit: StackFit.expand,
|
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||||
children: [
|
child: Text(
|
||||||
if (widget.coverUrl != null)
|
widget.albumName,
|
||||||
CachedNetworkImage(
|
style: TextStyle(
|
||||||
imageUrl: widget.coverUrl!,
|
color: colorScheme.onSurface,
|
||||||
fit: BoxFit.cover,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.black.withValues(alpha: 0.5),
|
fontSize: 16,
|
||||||
colorBlendMode: BlendMode.darken,
|
),
|
||||||
memCacheWidth: 600,
|
maxLines: 1,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
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],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 60),
|
|
||||||
child: Container(
|
|
||||||
width: 140,
|
|
||||||
height: 140,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: const Offset(0, 10),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: widget.coverUrl != null
|
|
||||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
|
||||||
: Container(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
),
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||||
|
final showContent = collapseRatio > 0.3;
|
||||||
|
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
collapseMode: CollapseMode.none,
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Background with dominant color
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
bgColor,
|
||||||
|
bgColor.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.6, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Cover image centered - fade out when collapsing
|
||||||
|
AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
opacity: showContent ? 1.0 : 0.0,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 30,
|
||||||
|
offset: const Offset(0, 15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: (coverSize * 2).toInt(),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: Container(
|
icon: Container(
|
||||||
@@ -321,7 +432,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('${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 +485,13 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('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(),
|
const Spacer(),
|
||||||
if (!_isSelectionMode)
|
if (!_isSelectionMode)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||||
icon: const Icon(Icons.checklist, size: 18),
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
label: const Text('Select'),
|
label: Text(context.l10n.actionSelect),
|
||||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -390,16 +501,84 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||||
return SliverList(
|
// Check if album has multiple discs
|
||||||
delegate: SliverChildBuilderDelegate(
|
if (!_hasMultipleDiscs(tracks)) {
|
||||||
(context, index) {
|
// Single disc - use simple list
|
||||||
final track = tracks[index];
|
return SliverList(
|
||||||
return KeyedSubtree(
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
final track = tracks[index];
|
||||||
|
return KeyedSubtree(
|
||||||
|
key: ValueKey(track.id),
|
||||||
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: tracks.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple discs - build list with separators
|
||||||
|
final discNumbers = _getDiscNumbers(tracks);
|
||||||
|
final List<Widget> children = [];
|
||||||
|
|
||||||
|
for (final discNumber in discNumbers) {
|
||||||
|
final discTracks = _getTracksForDisc(tracks, discNumber);
|
||||||
|
if (discTracks.isEmpty) continue;
|
||||||
|
|
||||||
|
// Add disc separator
|
||||||
|
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
|
||||||
|
|
||||||
|
// Add tracks for this disc
|
||||||
|
for (final track in discTracks) {
|
||||||
|
children.add(
|
||||||
|
KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _buildTrackItem(context, colorScheme, track),
|
child: _buildTrackItem(context, colorScheme, track),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
childCount: tracks.length,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverList(
|
||||||
|
delegate: SliverChildListDelegate(children),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 1,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -521,11 +700,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'$selectedCount selected',
|
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
Text(
|
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),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -540,7 +719,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
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),
|
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -553,8 +732,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
label: Text(
|
label: Text(
|
||||||
selectedCount > 0
|
selectedCount > 0
|
||||||
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
|
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
|
||||||
: 'Select tracks to delete',
|
: context.l10n.downloadedAlbumSelectToDelete,
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
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/track_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
|
||||||
class HomeScreen extends ConsumerStatefulWidget {
|
class HomeScreen extends ConsumerStatefulWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
@@ -73,7 +75,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 0:
|
case 0:
|
||||||
// Already on home
|
|
||||||
break;
|
break;
|
||||||
case 1:
|
case 1:
|
||||||
context.push('/queue');
|
context.push('/queue');
|
||||||
@@ -110,7 +111,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
body: Column(
|
body: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// URL Input
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
@@ -130,7 +130,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error message
|
|
||||||
if (trackState.error != null)
|
if (trackState.error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
@@ -140,15 +139,12 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Loading indicator
|
|
||||||
if (trackState.isLoading)
|
if (trackState.isLoading)
|
||||||
LinearProgressIndicator(color: colorScheme.primary),
|
LinearProgressIndicator(color: colorScheme.primary),
|
||||||
|
|
||||||
// Album/Playlist header
|
|
||||||
if (trackState.albumName != null || trackState.playlistName != null)
|
if (trackState.albumName != null || trackState.playlistName != null)
|
||||||
_buildHeader(trackState, colorScheme),
|
_buildHeader(trackState, colorScheme),
|
||||||
|
|
||||||
// Download All button
|
|
||||||
if (trackState.tracks.length > 1)
|
if (trackState.tracks.length > 1)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
@@ -162,7 +158,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Track list
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: trackState.tracks.isEmpty
|
child: trackState.tracks.isEmpty
|
||||||
? _buildEmptyState(colorScheme)
|
? _buildEmptyState(colorScheme)
|
||||||
@@ -250,7 +245,6 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Play all button
|
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: _downloadAll,
|
onPressed: _downloadAll,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -267,6 +261,22 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
|
|
||||||
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
Widget _buildTrackTile(int index, ColorScheme colorScheme) {
|
||||||
final track = ref.watch(trackProvider).tracks[index];
|
final track = ref.watch(trackProvider).tracks[index];
|
||||||
|
final isCollection = track.isCollection;
|
||||||
|
|
||||||
|
String subtitleText;
|
||||||
|
if (isCollection) {
|
||||||
|
final typeLabel = track.albumType ?? (track.isPlaylistItem ? 'Playlist' : 'Album');
|
||||||
|
final capitalizedType = typeLabel.isNotEmpty
|
||||||
|
? '${typeLabel[0].toUpperCase()}${typeLabel.substring(1)}'
|
||||||
|
: 'Album';
|
||||||
|
final year = track.releaseDate != null && track.releaseDate!.length >= 4
|
||||||
|
? track.releaseDate!.substring(0, 4)
|
||||||
|
: '';
|
||||||
|
subtitleText = '$capitalizedType • ${track.artistName}${year.isNotEmpty ? ' • $year' : ''}';
|
||||||
|
} else {
|
||||||
|
subtitleText = track.artistName;
|
||||||
|
}
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: track.coverUrl != null
|
leading: track.coverUrl != null
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
@@ -285,22 +295,85 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
color: colorScheme.surfaceContainerHighest,
|
color: colorScheme.surfaceContainerHighest,
|
||||||
borderRadius: BorderRadius.circular(8),
|
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),
|
title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
track.artistName,
|
subtitleText,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
trailing: Text(
|
trailing: isCollection
|
||||||
_formatDuration(track.duration),
|
? Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant)
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
: Text(
|
||||||
color: colorScheme.onSurfaceVariant,
|
_formatDuration(track.duration),
|
||||||
),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
),
|
color: colorScheme.onSurfaceVariant,
|
||||||
onTap: () => _downloadTrack(index),
|
),
|
||||||
|
),
|
||||||
|
onTap: () => isCollection ? _openCollection(track) : _downloadTrack(index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openCollection(Track track) async {
|
||||||
|
final extensionId = track.source;
|
||||||
|
if (extensionId == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (track.isAlbumItem) {
|
||||||
|
final albumData = await PlatformBridge.getAlbumWithExtension(extensionId, track.id);
|
||||||
|
if (albumData != null && mounted) {
|
||||||
|
final trackList = albumData['tracks'] as List<dynamic>? ?? [];
|
||||||
|
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
|
||||||
|
ref.read(trackProvider.notifier).setTracksFromCollection(
|
||||||
|
tracks: tracks,
|
||||||
|
albumName: albumData['name'] as String? ?? track.name,
|
||||||
|
coverUrl: albumData['cover_url'] as String? ?? track.coverUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (track.isPlaylistItem) {
|
||||||
|
final playlistData = await PlatformBridge.getPlaylistWithExtension(extensionId, track.id);
|
||||||
|
if (playlistData != null && mounted) {
|
||||||
|
final trackList = playlistData['tracks'] as List<dynamic>? ?? [];
|
||||||
|
final tracks = trackList.map((t) => _parseExtensionTrack(t as Map<String, dynamic>, extensionId)).toList();
|
||||||
|
ref.read(trackProvider.notifier).setTracksFromCollection(
|
||||||
|
tracks: tracks,
|
||||||
|
playlistName: playlistData['name'] as String? ?? track.name,
|
||||||
|
coverUrl: playlistData['cover_url'] as String? ?? track.coverUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to load: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Track _parseExtensionTrack(Map<String, dynamic> data, String source) {
|
||||||
|
int durationMs = 0;
|
||||||
|
final durationValue = data['duration_ms'];
|
||||||
|
if (durationValue is int) {
|
||||||
|
durationMs = durationValue;
|
||||||
|
} else if (durationValue is double) {
|
||||||
|
durationMs = durationValue.toInt();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Track(
|
||||||
|
id: (data['id'] ?? '').toString(),
|
||||||
|
name: (data['name'] ?? '').toString(),
|
||||||
|
artistName: (data['artists'] ?? '').toString(),
|
||||||
|
albumName: (data['album_name'] ?? '').toString(),
|
||||||
|
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||||
|
duration: (durationMs / 1000).round(),
|
||||||
|
releaseDate: data['release_date']?.toString(),
|
||||||
|
source: source,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/track_provider.dart';
|
import 'package:spotiflac_android/providers/track_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/home_tab.dart';
|
import 'package:spotiflac_android/screens/home_tab.dart';
|
||||||
import 'package:spotiflac_android/screens/store_tab.dart';
|
import 'package:spotiflac_android/screens/store_tab.dart';
|
||||||
@@ -28,13 +30,12 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
late PageController _pageController;
|
late PageController _pageController;
|
||||||
bool _hasCheckedUpdate = false;
|
bool _hasCheckedUpdate = false;
|
||||||
StreamSubscription<String>? _shareSubscription;
|
StreamSubscription<String>? _shareSubscription;
|
||||||
DateTime? _lastBackPress; // For double-tap to exit
|
DateTime? _lastBackPress;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pageController = PageController(initialPage: _currentIndex);
|
_pageController = PageController(initialPage: _currentIndex);
|
||||||
// Check for updates after first frame
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
_setupShareListener();
|
_setupShareListener();
|
||||||
@@ -42,14 +43,12 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _setupShareListener() {
|
void _setupShareListener() {
|
||||||
// Check for pending URL that was received before listener was ready
|
|
||||||
final pendingUrl = ShareIntentService().consumePendingUrl();
|
final pendingUrl = ShareIntentService().consumePendingUrl();
|
||||||
if (pendingUrl != null) {
|
if (pendingUrl != null) {
|
||||||
_log.d('Processing pending shared URL: $pendingUrl');
|
_log.d('Processing pending shared URL: $pendingUrl');
|
||||||
_handleSharedUrl(pendingUrl);
|
_handleSharedUrl(pendingUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for future shared URLs with error handling
|
|
||||||
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
_shareSubscription = ShareIntentService().sharedUrlStream.listen(
|
||||||
(url) {
|
(url) {
|
||||||
_log.d('Received shared URL from stream: $url');
|
_log.d('Received shared URL from stream: $url');
|
||||||
@@ -63,21 +62,16 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleSharedUrl(String url) {
|
void _handleSharedUrl(String url) {
|
||||||
// Pop any existing screens (Album, Artist, Settings sub-pages) to return to root
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
|
||||||
// Navigate to Home tab
|
|
||||||
if (_currentIndex != 0) {
|
if (_currentIndex != 0) {
|
||||||
_onNavTap(0);
|
_onNavTap(0);
|
||||||
}
|
}
|
||||||
// Fetch metadata for shared URL
|
|
||||||
ref.read(trackProvider.notifier).fetchFromUrl(url);
|
ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||||
// Mark that user has searched (hide helper text)
|
|
||||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||||
// Show snackbar
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Loading shared link...')),
|
SnackBar(content: Text(context.l10n.loadingSharedLink)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,8 +116,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void _onPageChanged(int index) {
|
void _onPageChanged(int index) {
|
||||||
if (_currentIndex != index) {
|
if (_currentIndex != index) {
|
||||||
setState(() => _currentIndex = index);
|
setState(() => _currentIndex = index);
|
||||||
// Unfocus any text field when switching tabs to prevent keyboard from appearing
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
FocusScope.of(context).unfocus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,40 +124,41 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
void _handleBackPress() {
|
void _handleBackPress() {
|
||||||
final trackState = ref.read(trackProvider);
|
final trackState = ref.read(trackProvider);
|
||||||
|
|
||||||
// Check if keyboard is visible - if so, just dismiss keyboard, don't clear search
|
|
||||||
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
if (isKeyboardVisible) {
|
if (isKeyboardVisible) {
|
||||||
FocusScope.of(context).unfocus();
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentIndex == 0 && trackState.isShowingRecentAccess) {
|
||||||
|
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If on Home tab and has text in search bar or has content (but not loading), clear it
|
|
||||||
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) {
|
||||||
ref.read(trackProvider.notifier).clear();
|
ref.read(trackProvider.notifier).clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not on Home tab, go to Home tab first
|
|
||||||
if (_currentIndex != 0) {
|
if (_currentIndex != 0) {
|
||||||
_onNavTap(0);
|
_onNavTap(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If loading, ignore back press
|
|
||||||
if (trackState.isLoading) {
|
if (trackState.isLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double-tap to exit
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
if (_lastBackPress != null && now.difference(_lastBackPress!) < const Duration(seconds: 2)) {
|
||||||
SystemNavigator.pop();
|
SystemNavigator.pop();
|
||||||
} else {
|
} else {
|
||||||
_lastBackPress = now;
|
_lastBackPress = now;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Press back again to exit'),
|
content: Text(context.l10n.pressBackAgainToExit),
|
||||||
duration: Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -176,8 +170,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount));
|
||||||
final trackState = ref.watch(trackProvider);
|
final trackState = ref.watch(trackProvider);
|
||||||
final showStore = ref.watch(settingsProvider.select((s) => s.showExtensionStore));
|
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;
|
final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||||
|
|
||||||
// Determine if we can pop (for predictive back animation)
|
// Determine if we can pop (for predictive back animation)
|
||||||
@@ -187,9 +181,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
!trackState.hasSearchText &&
|
!trackState.hasSearchText &&
|
||||||
!trackState.hasContent &&
|
!trackState.hasContent &&
|
||||||
!trackState.isLoading &&
|
!trackState.isLoading &&
|
||||||
|
!trackState.isShowingRecentAccess &&
|
||||||
!isKeyboardVisible;
|
!isKeyboardVisible;
|
||||||
|
|
||||||
// Build tabs and destinations based on settings
|
|
||||||
final tabs = <Widget>[
|
final tabs = <Widget>[
|
||||||
const HomeTab(),
|
const HomeTab(),
|
||||||
QueueTab(
|
QueueTab(
|
||||||
@@ -201,11 +195,12 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
const SettingsTab(),
|
const SettingsTab(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final l10n = context.l10n;
|
||||||
final destinations = <NavigationDestination>[
|
final destinations = <NavigationDestination>[
|
||||||
const NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
selectedIcon: Icon(Icons.home),
|
selectedIcon: const Icon(Icons.home),
|
||||||
label: 'Home',
|
label: l10n.navHome,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Badge(
|
icon: Badge(
|
||||||
@@ -218,22 +213,29 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
label: Text('$queueState'),
|
label: Text('$queueState'),
|
||||||
child: const Icon(Icons.history),
|
child: const Icon(Icons.history),
|
||||||
),
|
),
|
||||||
label: 'History',
|
label: l10n.navHistory,
|
||||||
),
|
),
|
||||||
if (showStore)
|
if (showStore)
|
||||||
const NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.store_outlined),
|
icon: Badge(
|
||||||
selectedIcon: Icon(Icons.store),
|
isLabelVisible: storeUpdatesCount > 0,
|
||||||
label: 'Store',
|
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(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.settings_outlined),
|
icon: const Icon(Icons.settings_outlined),
|
||||||
selectedIcon: Icon(Icons.settings),
|
selectedIcon: const Icon(Icons.settings),
|
||||||
label: 'Settings',
|
label: l10n.navSettings,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Clamp current index if tabs changed
|
|
||||||
final maxIndex = tabs.length - 1;
|
final maxIndex = tabs.length - 1;
|
||||||
if (_currentIndex > maxIndex) {
|
if (_currentIndex > maxIndex) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -253,7 +255,6 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle back press manually when canPop is false
|
|
||||||
_handleBackPress();
|
_handleBackPress();
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
@@ -9,7 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
import 'package:spotiflac_android/widgets/download_service_picker.dart';
|
||||||
|
|
||||||
/// Playlist detail screen with Material Expressive 3 design
|
/// Playlist detail screen with Material Expressive 3 design
|
||||||
class PlaylistScreen extends ConsumerWidget {
|
class PlaylistScreen extends ConsumerStatefulWidget {
|
||||||
final String playlistName;
|
final String playlistName;
|
||||||
final String? coverUrl;
|
final String? coverUrl;
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
@@ -22,16 +24,66 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
||||||
|
Color? _dominantColor;
|
||||||
|
bool _showTitleInAppBar = false;
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_extractDominantColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScroll);
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
final shouldShow = _scrollController.offset > 280;
|
||||||
|
if (shouldShow != _showTitleInAppBar) {
|
||||||
|
setState(() => _showTitleInAppBar = shouldShow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _extractDominantColor() async {
|
||||||
|
if (widget.coverUrl == null) return;
|
||||||
|
try {
|
||||||
|
final paletteGenerator = await PaletteGenerator.fromImageProvider(
|
||||||
|
CachedNetworkImageProvider(widget.coverUrl!),
|
||||||
|
maximumColorCount: 16,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_dominantColor = paletteGenerator.dominantColor?.color ??
|
||||||
|
paletteGenerator.vibrantColor?.color ??
|
||||||
|
paletteGenerator.mutedColor?.color;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore palette extraction errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
_buildAppBar(context, colorScheme),
|
_buildAppBar(context, colorScheme),
|
||||||
_buildInfoCard(context, ref, colorScheme),
|
_buildInfoCard(context, colorScheme),
|
||||||
_buildTrackListHeader(context, colorScheme),
|
_buildTrackListHeader(context, colorScheme),
|
||||||
_buildTrackList(context, ref, colorScheme),
|
_buildTrackList(context, colorScheme),
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -39,59 +91,114 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||||
|
final bgColor = _dominantColor ?? colorScheme.surface;
|
||||||
|
|
||||||
return SliverAppBar(
|
return SliverAppBar(
|
||||||
expandedHeight: 280,
|
expandedHeight: 320,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
stretch: true,
|
stretch: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
title: AnimatedOpacity(
|
||||||
background: Stack(
|
duration: const Duration(milliseconds: 200),
|
||||||
fit: StackFit.expand,
|
opacity: _showTitleInAppBar ? 1.0 : 0.0,
|
||||||
children: [
|
child: Text(
|
||||||
if (coverUrl != null)
|
widget.playlistName,
|
||||||
CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
style: TextStyle(
|
||||||
Container(
|
color: colorScheme.onSurface,
|
||||||
decoration: BoxDecoration(
|
fontWeight: FontWeight.w600,
|
||||||
gradient: LinearGradient(
|
fontSize: 16,
|
||||||
begin: Alignment.topCenter,
|
),
|
||||||
end: Alignment.bottomCenter,
|
maxLines: 1,
|
||||||
colors: [Colors.transparent, colorScheme.surface.withValues(alpha: 0.8), colorScheme.surface],
|
overflow: TextOverflow.ellipsis,
|
||||||
stops: const [0.0, 0.7, 1.0],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 60),
|
|
||||||
child: Container(
|
|
||||||
width: 140,
|
|
||||||
height: 140,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: coverUrl != null
|
|
||||||
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
|
||||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
),
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||||
|
final showContent = collapseRatio > 0.3;
|
||||||
|
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
collapseMode: CollapseMode.none,
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// Background with dominant color
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 500),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
bgColor,
|
||||||
|
bgColor.withValues(alpha: 0.8),
|
||||||
|
colorScheme.surface,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.6, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Cover image centered - fade out when collapsing
|
||||||
|
AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
opacity: showContent ? 1.0 : 0.0,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Container(
|
||||||
|
width: coverSize,
|
||||||
|
height: coverSize,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 30,
|
||||||
|
offset: const Offset(0, 15),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: widget.coverUrl != null
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: widget.coverUrl!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
memCacheWidth: (coverSize * 2).toInt(),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
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: colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -104,7 +211,7 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
@@ -114,15 +221,15 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _downloadAll(context, ref),
|
onPressed: () => _downloadAll(context),
|
||||||
icon: const Icon(Icons.download),
|
icon: const Icon(Icons.download),
|
||||||
label: Text('Download All (${tracks.length})'),
|
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
|
||||||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -141,32 +248,32 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('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)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
|
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final track = tracks[index];
|
final track = widget.tracks[index];
|
||||||
return KeyedSubtree(
|
return KeyedSubtree(
|
||||||
key: ValueKey(track.id),
|
key: ValueKey(track.id),
|
||||||
child: _PlaylistTrackItem(
|
child: _PlaylistTrackItem(
|
||||||
track: track,
|
track: track,
|
||||||
onDownload: () => _downloadTrack(context, ref, track),
|
onDownload: () => _downloadTrack(context, track),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: tracks.length,
|
childCount: widget.tracks.length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
|
void _downloadTrack(BuildContext context, Track track) {
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
@@ -176,31 +283,31 @@ class PlaylistScreen extends ConsumerWidget {
|
|||||||
coverUrl: track.coverUrl,
|
coverUrl: track.coverUrl,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
|
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 {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService);
|
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))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _downloadAll(BuildContext context, WidgetRef ref) {
|
void _downloadAll(BuildContext context) {
|
||||||
if (tracks.isEmpty) return;
|
if (widget.tracks.isEmpty) return;
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
if (settings.askQualityBeforeDownload) {
|
if (settings.askQualityBeforeDownload) {
|
||||||
DownloadServicePicker.show(
|
DownloadServicePicker.show(
|
||||||
context,
|
context,
|
||||||
trackName: '${tracks.length} tracks',
|
trackName: '${widget.tracks.length} tracks',
|
||||||
artistName: playlistName,
|
artistName: widget.playlistName,
|
||||||
onSelect: (quality, service) {
|
onSelect: (quality, service) {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.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(widget.tracks.length))));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.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(widget.tracks.length))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,12 +323,10 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Only watch the specific item for this track
|
|
||||||
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
final queueItem = ref.watch(downloadQueueProvider.select((state) {
|
||||||
return state.items.where((item) => item.track.id == track.id).firstOrNull;
|
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) {
|
final isInHistory = ref.watch(downloadHistoryProvider.select((state) {
|
||||||
return state.isDownloaded(track.id);
|
return state.isDownloaded(track.id);
|
||||||
}));
|
}));
|
||||||
@@ -232,7 +337,6 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
final isCompleted = queueItem?.status == DownloadStatus.completed;
|
||||||
final progress = queueItem?.progress ?? 0.0;
|
final progress = queueItem?.progress ?? 0.0;
|
||||||
|
|
||||||
// Show as downloaded if in queue completed OR in history
|
|
||||||
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
final showAsDownloaded = isCompleted || (!isQueued && isInHistory);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -264,7 +368,7 @@ class _PlaylistTrackItem extends ConsumerWidget {
|
|||||||
final fileExists = await File(historyItem.filePath).exists();
|
final fileExists = await File(historyItem.filePath).exists();
|
||||||
if (fileExists) {
|
if (fileExists) {
|
||||||
if (context.mounted) {
|
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;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
|
|
||||||
@@ -14,19 +15,19 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Download Queue'),
|
title: Text(context.l10n.queueTitle),
|
||||||
actions: [
|
actions: [
|
||||||
if (queueState.items.isNotEmpty)
|
if (queueState.items.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_sweep),
|
icon: const Icon(Icons.delete_sweep),
|
||||||
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
onPressed: () => ref.read(downloadQueueProvider.notifier).clearCompleted(),
|
||||||
tooltip: 'Clear completed',
|
tooltip: context.l10n.queueClearCompleted,
|
||||||
),
|
),
|
||||||
if (queueState.items.isNotEmpty)
|
if (queueState.items.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.clear_all),
|
icon: const Icon(Icons.clear_all),
|
||||||
onPressed: () => _showClearAllDialog(context, ref),
|
onPressed: () => _showClearAllDialog(context, ref),
|
||||||
tooltip: 'Clear all',
|
tooltip: context.l10n.queueClearAll,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -51,14 +52,14 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No downloads in queue',
|
context.l10n.queueEmpty,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Add tracks from the home screen',
|
context.l10n.queueEmptySubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
@@ -177,7 +178,7 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(Icons.error, color: colorScheme.error),
|
Icon(Icons.error, color: colorScheme.error),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Text('Download Failed'),
|
Text(context.l10n.queueDownloadFailed),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
@@ -185,10 +186,10 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text('Track: ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
Text('${context.l10n.queueTrackLabel} ${item.track.name}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
Text('Artist: ${item.track.artistName}'),
|
Text('${context.l10n.queueArtistLabel} ${item.track.artistName}'),
|
||||||
const SizedBox(height: 16),
|
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),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -197,7 +198,7 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
item.error ?? 'Unknown error',
|
item.error ?? context.l10n.queueUnknownError,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -211,7 +212,7 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Close'),
|
child: Text(context.l10n.dialogClose),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -223,19 +224,19 @@ class QueueScreen extends ConsumerWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Clear All'),
|
title: Text(context.l10n.queueClearAll),
|
||||||
content: const Text('Are you sure you want to clear all downloads?'),
|
content: Text(context.l10n.queueClearAllMessage),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.read(downloadQueueProvider.notifier).clearAll();
|
ref.read(downloadQueueProvider.notifier).clearAll();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: Text('Clear', style: TextStyle(color: colorScheme.error)),
|
child: Text(context.l10n.dialogClear, style: TextStyle(color: colorScheme.error)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:open_filex/open_filex.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/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
@@ -50,11 +52,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final Set<String> _pendingChecks = {};
|
final Set<String> _pendingChecks = {};
|
||||||
static const int _maxCacheSize = 500;
|
static const int _maxCacheSize = 500;
|
||||||
|
|
||||||
// Multi-select state
|
|
||||||
bool _isSelectionMode = false;
|
bool _isSelectionMode = false;
|
||||||
final Set<String> _selectedIds = {};
|
final Set<String> _selectedIds = {};
|
||||||
|
|
||||||
// Filter page controller for swipe between All/Albums/Singles
|
|
||||||
PageController? _filterPageController;
|
PageController? _filterPageController;
|
||||||
final List<String> _filterModes = ['all', 'albums', 'singles'];
|
final List<String> _filterModes = ['all', 'albums', 'singles'];
|
||||||
bool _isPageControllerInitialized = false;
|
bool _isPageControllerInitialized = false;
|
||||||
@@ -64,7 +64,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Will be initialized in build when we have access to ref
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializePageController() {
|
void _initializePageController() {
|
||||||
@@ -138,21 +137,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: const Text('Delete Selected'),
|
title: Text(context.l10n.dialogDeleteSelectedTitle),
|
||||||
content: Text(
|
content: Text(context.l10n.dialogDeleteSelectedMessage(count)),
|
||||||
'Delete $count ${count == 1 ? 'track' : 'tracks'} from history?\n\nThis will also delete the files from storage.',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(ctx, true),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(context).colorScheme.error,
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
child: const Text('Delete'),
|
child: Text(context.l10n.dialogDelete),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -183,9 +180,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
|
||||||
'Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -228,35 +223,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
Future<void> _openFile(String filePath) async {
|
Future<void> _openFile(String filePath) async {
|
||||||
final cleanPath = _cleanFilePath(filePath);
|
final cleanPath = _cleanFilePath(filePath);
|
||||||
try {
|
try {
|
||||||
// Determine MIME type based on file extension
|
final mimeType = audioMimeTypeForPath(cleanPath);
|
||||||
final extension = cleanPath.split('.').last.toLowerCase();
|
|
||||||
String mimeType;
|
|
||||||
switch (extension) {
|
|
||||||
case 'flac':
|
|
||||||
mimeType = 'audio/flac';
|
|
||||||
break;
|
|
||||||
case 'mp3':
|
|
||||||
mimeType = 'audio/mpeg';
|
|
||||||
break;
|
|
||||||
case 'wav':
|
|
||||||
mimeType = 'audio/wav';
|
|
||||||
break;
|
|
||||||
case 'm4a':
|
|
||||||
case 'aac':
|
|
||||||
mimeType = 'audio/mp4';
|
|
||||||
break;
|
|
||||||
case 'ogg':
|
|
||||||
mimeType = 'audio/ogg';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
mimeType = 'audio/*';
|
|
||||||
}
|
|
||||||
await OpenFilex.open(cleanPath, type: mimeType);
|
await OpenFilex.open(cleanPath, type: mimeType);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text('Cannot open file: $e')));
|
).showSnackBar(SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +288,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
) {
|
) {
|
||||||
if (filterMode == 'all') return items;
|
if (filterMode == 'all') return items;
|
||||||
|
|
||||||
// Count tracks per album
|
|
||||||
final albumCounts = <String, int>{};
|
final albumCounts = <String, int>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
@@ -324,14 +296,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
switch (filterMode) {
|
switch (filterMode) {
|
||||||
case 'albums':
|
case 'albums':
|
||||||
// Album = more than 1 track from same album in history
|
|
||||||
return items.where((item) {
|
return items.where((item) {
|
||||||
final key =
|
final key =
|
||||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
return (albumCounts[key] ?? 0) > 1;
|
return (albumCounts[key] ?? 0) > 1;
|
||||||
}).toList();
|
}).toList();
|
||||||
case 'singles':
|
case 'singles':
|
||||||
// Single = only 1 track from that album in history
|
|
||||||
return items.where((item) {
|
return items.where((item) {
|
||||||
final key =
|
final key =
|
||||||
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
'${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
@@ -344,7 +314,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
/// Count albums vs singles for filter chips
|
/// Count albums vs singles for filter chips
|
||||||
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
|
Map<String, int> _countAlbumsAndSingles(List<DownloadHistoryItem> items) {
|
||||||
// Count tracks per album
|
|
||||||
final albumCounts = <String, int>{};
|
final albumCounts = <String, int>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
final key = '${item.albumName}|${item.albumArtist ?? item.artistName}';
|
||||||
@@ -375,11 +344,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
albumMap.putIfAbsent(key, () => []).add(item);
|
albumMap.putIfAbsent(key, () => []).add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include albums with more than 1 track
|
|
||||||
final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map(
|
final groupedAlbums = albumMap.entries.where((e) => e.value.length > 1).map(
|
||||||
(e) {
|
(e) {
|
||||||
final tracks = e.value;
|
final tracks = e.value;
|
||||||
// Sort tracks by track number
|
|
||||||
tracks.sort((a, b) {
|
tracks.sort((a, b) {
|
||||||
final aNum = a.trackNumber ?? 999;
|
final aNum = a.trackNumber ?? 999;
|
||||||
final bNum = b.trackNumber ?? 999;
|
final bNum = b.trackNumber ?? 999;
|
||||||
@@ -398,7 +365,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
},
|
},
|
||||||
).toList();
|
).toList();
|
||||||
|
|
||||||
// Sort by latest download
|
|
||||||
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
|
groupedAlbums.sort((a, b) => b.latestDownload.compareTo(a.latestDownload));
|
||||||
|
|
||||||
return groupedAlbums;
|
return groupedAlbums;
|
||||||
@@ -412,7 +378,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
albumKeys.add(key);
|
albumKeys.add(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count albums with more than 1 track
|
|
||||||
int count = 0;
|
int count = 0;
|
||||||
for (final key in albumKeys) {
|
for (final key in albumKeys) {
|
||||||
final trackCount = items
|
final trackCount = items
|
||||||
@@ -445,7 +410,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Initialize page controller on first build
|
|
||||||
_initializePageController();
|
_initializePageController();
|
||||||
|
|
||||||
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||||
@@ -471,10 +435,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
// Group albums for Albums filter view
|
|
||||||
final groupedAlbums = _groupByAlbum(allHistoryItems);
|
final groupedAlbums = _groupByAlbum(allHistoryItems);
|
||||||
|
|
||||||
// Count for filter chips
|
|
||||||
final counts = _countAlbumsAndSingles(allHistoryItems);
|
final counts = _countAlbumsAndSingles(allHistoryItems);
|
||||||
final albumCount = _countUniqueAlbums(allHistoryItems);
|
final albumCount = _countUniqueAlbums(allHistoryItems);
|
||||||
final singleCount = counts['singles'] ?? 0;
|
final singleCount = counts['singles'] ?? 0;
|
||||||
@@ -490,9 +452,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
},
|
},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
NestedScrollView(
|
// ScrollConfiguration disables stretch overscroll to fix _StretchController exception
|
||||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
// This is a known Flutter issue with NestedScrollView + Material 3 stretch indicator
|
||||||
// App Bar - always normal style
|
ScrollConfiguration(
|
||||||
|
behavior: ScrollConfiguration.of(context).copyWith(
|
||||||
|
overscroll: false,
|
||||||
|
),
|
||||||
|
child: NestedScrollView(
|
||||||
|
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -514,7 +481,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'History',
|
context.l10n.historyTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (14 * expandRatio),
|
fontSize: 20 + (14 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -526,7 +493,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Pause/Resume controls
|
|
||||||
if ((isProcessing || queuedCount > 0) &&
|
if ((isProcessing || queuedCount > 0) &&
|
||||||
(queueItems.length > 1 || isPaused))
|
(queueItems.length > 1 || isPaused))
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -572,10 +538,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Queue header
|
|
||||||
if (queueItems.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -586,10 +551,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Queue list
|
|
||||||
if (queueItems.isNotEmpty)
|
if (queueItems.isNotEmpty)
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
@@ -601,7 +565,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}, childCount: queueItems.length),
|
}, childCount: queueItems.length),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Filter chips (only show when history has items)
|
|
||||||
if (allHistoryItems.isNotEmpty)
|
if (allHistoryItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -611,7 +574,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'All',
|
label: context.l10n.historyFilterAll,
|
||||||
count: allHistoryItems.length,
|
count: allHistoryItems.length,
|
||||||
isSelected: historyFilterMode == 'all',
|
isSelected: historyFilterMode == 'all',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -620,7 +583,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'Albums',
|
label: context.l10n.historyFilterAlbums,
|
||||||
count: albumCount,
|
count: albumCount,
|
||||||
isSelected: historyFilterMode == 'albums',
|
isSelected: historyFilterMode == 'albums',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -629,7 +592,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_FilterChip(
|
_FilterChip(
|
||||||
label: 'Singles',
|
label: context.l10n.historyFilterSingles,
|
||||||
count: singleCount,
|
count: singleCount,
|
||||||
isSelected: historyFilterMode == 'singles',
|
isSelected: historyFilterMode == 'singles',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -654,7 +617,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
if (notification is OverscrollNotification) {
|
if (notification is OverscrollNotification) {
|
||||||
final overscroll = notification.overscroll;
|
final overscroll = notification.overscroll;
|
||||||
|
|
||||||
// At first page and overscrolling to the left -> push parent toward Home
|
|
||||||
if (page == 0 && overscroll < 0) {
|
if (page == 0 && overscroll < 0) {
|
||||||
final currentOffset = parentController.offset;
|
final currentOffset = parentController.offset;
|
||||||
final targetOffset = (currentOffset + overscroll).clamp(
|
final targetOffset = (currentOffset + overscroll).clamp(
|
||||||
@@ -665,7 +627,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// At last page and overscrolling to the right -> push parent toward next tab
|
|
||||||
if (page == 2 && overscroll > 0) {
|
if (page == 2 && overscroll > 0) {
|
||||||
final currentOffset = parentController.offset;
|
final currentOffset = parentController.offset;
|
||||||
final targetOffset = (currentOffset + overscroll).clamp(
|
final targetOffset = (currentOffset + overscroll).clamp(
|
||||||
@@ -677,32 +638,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snap parent to nearest page when scroll ends
|
|
||||||
if (notification is ScrollEndNotification) {
|
if (notification is ScrollEndNotification) {
|
||||||
if (page == 0 || page == 2) {
|
if (page == 0 || page == 2) {
|
||||||
final currentPage = parentController.page ?? widget.parentPageIndex.toDouble();
|
final currentPage = parentController.page ?? widget.parentPageIndex.toDouble();
|
||||||
final historyPage = widget.parentPageIndex.toDouble();
|
final historyPage = widget.parentPageIndex.toDouble();
|
||||||
final offset = currentPage - historyPage;
|
final offset = currentPage - historyPage;
|
||||||
|
|
||||||
// Only snap if we've moved the parent
|
|
||||||
if (offset.abs() > 0.01) {
|
if (offset.abs() > 0.01) {
|
||||||
// Use 0.3 threshold (30%)
|
|
||||||
if (offset < -0.3) {
|
if (offset < -0.3) {
|
||||||
// Swiped enough toward Home - animate to Home
|
|
||||||
parentController.animateToPage(
|
parentController.animateToPage(
|
||||||
widget.parentPageIndex - 1,
|
widget.parentPageIndex - 1,
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
);
|
);
|
||||||
} else if (offset > 0.3) {
|
} else if (offset > 0.3) {
|
||||||
// Swiped enough toward next tab - animate to next
|
|
||||||
parentController.animateToPage(
|
parentController.animateToPage(
|
||||||
widget.nextPageIndex ?? (widget.parentPageIndex + 1),
|
widget.nextPageIndex ?? (widget.parentPageIndex + 1),
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Not enough - instant jump back (no animation)
|
|
||||||
parentController.jumpToPage(widget.parentPageIndex);
|
parentController.jumpToPage(widget.parentPageIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,7 +671,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
onPageChanged: _onFilterPageChanged,
|
onPageChanged: _onFilterPageChanged,
|
||||||
children: [
|
children: [
|
||||||
// All tab
|
|
||||||
_buildFilterContent(
|
_buildFilterContent(
|
||||||
context: context,
|
context: context,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -726,7 +680,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
queueItems: queueItems,
|
queueItems: queueItems,
|
||||||
groupedAlbums: groupedAlbums,
|
groupedAlbums: groupedAlbums,
|
||||||
),
|
),
|
||||||
// Albums tab
|
|
||||||
_buildFilterContent(
|
_buildFilterContent(
|
||||||
context: context,
|
context: context,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -736,7 +689,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
queueItems: queueItems,
|
queueItems: queueItems,
|
||||||
groupedAlbums: groupedAlbums,
|
groupedAlbums: groupedAlbums,
|
||||||
),
|
),
|
||||||
// Singles tab
|
|
||||||
_buildFilterContent(
|
_buildFilterContent(
|
||||||
context: context,
|
context: context,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
@@ -750,8 +702,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
), // ScrollConfiguration
|
||||||
|
|
||||||
// Bottom Selection Action Bar
|
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
curve: Curves.easeOutCubic,
|
curve: Curves.easeOutCubic,
|
||||||
@@ -784,7 +736,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// History section header
|
|
||||||
if (historyItems.isNotEmpty &&
|
if (historyItems.isNotEmpty &&
|
||||||
queueItems.isEmpty &&
|
queueItems.isEmpty &&
|
||||||
filterMode != 'albums')
|
filterMode != 'albums')
|
||||||
@@ -805,7 +756,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
? () => _enterSelectionMode(historyItems.first.id)
|
? () => _enterSelectionMode(historyItems.first.id)
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(Icons.checklist, size: 18),
|
icon: const Icon(Icons.checklist, size: 18),
|
||||||
label: const Text('Select'),
|
label: Text(context.l10n.actionSelect),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
),
|
),
|
||||||
@@ -815,7 +766,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Albums section header (when Albums filter is selected)
|
|
||||||
if (groupedAlbums.isNotEmpty &&
|
if (groupedAlbums.isNotEmpty &&
|
||||||
queueItems.isEmpty &&
|
queueItems.isEmpty &&
|
||||||
filterMode == 'albums')
|
filterMode == 'albums')
|
||||||
@@ -831,7 +781,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// History section header when queue has items
|
|
||||||
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
if (historyItems.isNotEmpty && queueItems.isNotEmpty)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -845,7 +794,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Albums Grid (when Albums filter is selected)
|
|
||||||
if (filterMode == 'albums' && groupedAlbums.isNotEmpty)
|
if (filterMode == 'albums' && groupedAlbums.isNotEmpty)
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
@@ -867,7 +815,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// History - Grid or List (for All and Singles filter)
|
|
||||||
if (historyItems.isNotEmpty && filterMode != 'albums')
|
if (historyItems.isNotEmpty && filterMode != 'albums')
|
||||||
historyViewMode == 'grid'
|
historyViewMode == 'grid'
|
||||||
? SliverPadding(
|
? SliverPadding(
|
||||||
@@ -907,10 +854,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
colorScheme,
|
colorScheme,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}, childCount: historyItems.length),
|
}, childCount: historyItems.length ),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Empty state
|
|
||||||
if (queueItems.isEmpty &&
|
if (queueItems.isEmpty &&
|
||||||
historyItems.isEmpty &&
|
historyItems.isEmpty &&
|
||||||
(filterMode != 'albums' || groupedAlbums.isEmpty))
|
(filterMode != 'albums' || groupedAlbums.isEmpty))
|
||||||
@@ -923,7 +869,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
// Add bottom padding when selection mode is active to avoid overlap with bottom bar
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(height: _isSelectionMode ? 100 : 16),
|
child: SizedBox(height: _isSelectionMode ? 100 : 16),
|
||||||
),
|
),
|
||||||
@@ -992,7 +937,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Album cover with track count badge
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -1018,7 +962,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Track count badge
|
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 8,
|
right: 8,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
@@ -1056,16 +999,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
// Album name
|
|
||||||
Text(
|
Text(
|
||||||
album.albumName,
|
album.albumName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600 ),
|
||||||
),
|
),
|
||||||
// Artist name
|
|
||||||
Text(
|
Text(
|
||||||
album.artistName,
|
album.artistName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
@@ -1109,7 +1050,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Handle bar
|
|
||||||
Container(
|
Container(
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 4,
|
height: 4,
|
||||||
@@ -1120,10 +1060,8 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Selection info row
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Close button
|
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: _exitSelectionMode,
|
onPressed: _exitSelectionMode,
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
@@ -1133,7 +1071,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Selection count
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1154,7 +1091,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Select all toggle
|
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
@@ -1177,7 +1113,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Delete button
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
@@ -1485,7 +1420,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Quality badge
|
|
||||||
if (item.quality != null && item.quality!.contains('bit'))
|
if (item.quality != null && item.quality!.contains('bit'))
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 4,
|
left: 4,
|
||||||
@@ -1514,7 +1448,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Play button
|
|
||||||
if (fileExists && !_isSelectionMode)
|
if (fileExists && !_isSelectionMode)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 4,
|
right: 4,
|
||||||
@@ -1535,7 +1468,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Error indicator
|
|
||||||
if (!fileExists && !_isSelectionMode)
|
if (!fileExists && !_isSelectionMode)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 4,
|
right: 4,
|
||||||
@@ -1553,7 +1485,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Selection overlay
|
|
||||||
if (_isSelectionMode)
|
if (_isSelectionMode)
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -1586,7 +1517,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Selection checkbox
|
|
||||||
if (_isSelectionMode)
|
if (_isSelectionMode)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 4,
|
right: 4,
|
||||||
@@ -1654,7 +1584,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Selection checkbox
|
|
||||||
if (_isSelectionMode) ...[
|
if (_isSelectionMode) ...[
|
||||||
Container(
|
Container(
|
||||||
width: 24,
|
width: 24,
|
||||||
@@ -1681,7 +1610,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
],
|
],
|
||||||
// Cover art
|
|
||||||
item.coverUrl != null
|
item.coverUrl != null
|
||||||
? ClipRRect(
|
? ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -1708,7 +1636,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Track info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -1776,7 +1703,6 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Action buttons (hide in selection mode)
|
|
||||||
if (!_isSelectionMode)
|
if (!_isSelectionMode)
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class AboutPage extends StatelessWidget {
|
class AboutPage extends StatelessWidget {
|
||||||
@@ -17,7 +18,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -34,14 +34,12 @@ class AboutPage extends StatelessWidget {
|
|||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
// When collapsed (expandRatio=0): left=56 to avoid back button
|
final leftPadding = 56 - (32 * expandRatio);
|
||||||
// When expanded (expandRatio=1): left=24 for normal padding
|
|
||||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'About',
|
context.l10n.aboutTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -53,7 +51,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// App header card with logo and description
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
@@ -61,28 +58,27 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Contributors section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
|
||||||
child: SettingsSectionHeader(title: 'Contributors'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: AppInfo.mobileAuthor,
|
name: AppInfo.mobileAuthor,
|
||||||
description: 'Mobile version developer',
|
description: context.l10n.aboutMobileDeveloper,
|
||||||
githubUsername: AppInfo.mobileAuthor,
|
githubUsername: AppInfo.mobileAuthor,
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: AppInfo.originalAuthor,
|
name: AppInfo.originalAuthor,
|
||||||
description: 'Creator of the original SpotiFLAC',
|
description: context.l10n.aboutOriginalCreator,
|
||||||
githubUsername: AppInfo.originalAuthor,
|
githubUsername: AppInfo.originalAuthor,
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: 'Amonoman',
|
name: 'Amonoman',
|
||||||
description: 'The talented artist who created our beautiful app logo!',
|
description: context.l10n.aboutLogoArtist,
|
||||||
githubUsername: 'Amonoman',
|
githubUsername: 'Amonoman',
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -90,36 +86,42 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Special Thanks section
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: context.l10n.aboutTranslators),
|
||||||
|
),
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Special Thanks'),
|
child: _TranslatorsSection(),
|
||||||
|
),
|
||||||
|
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: 'uimaxbai',
|
name: 'binimum',
|
||||||
description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!',
|
description: context.l10n.aboutBinimumDesc,
|
||||||
githubUsername: 'uimaxbai',
|
githubUsername: 'binimum',
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_ContributorItem(
|
_ContributorItem(
|
||||||
name: 'sachinsenal0x64',
|
name: 'sachinsenal0x64',
|
||||||
description: 'The original HiFi project creator. The foundation of Tidal integration!',
|
description: context.l10n.aboutSachinsenalDesc,
|
||||||
githubUsername: 'sachinsenal0x64',
|
githubUsername: 'sachinsenal0x64',
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_AboutSettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
title: 'DoubleDouble',
|
title: context.l10n.aboutDoubleDouble,
|
||||||
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
subtitle: context.l10n.aboutDoubleDoubleDesc,
|
||||||
onTap: () => _launchUrl('https://doubledouble.top'),
|
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
_AboutSettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.music_note_outlined,
|
icon: Icons.music_note_outlined,
|
||||||
title: 'DAB Music',
|
title: context.l10n.aboutDabMusic,
|
||||||
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
subtitle: context.l10n.aboutDabMusicDesc,
|
||||||
onTap: () => _launchUrl('https://dabmusic.xyz'),
|
onTap: () => _launchUrl('https://dabmusic.xyz'),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -127,38 +129,37 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Links section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
|
||||||
child: SettingsSectionHeader(title: 'Links'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.phone_android,
|
icon: Icons.phone_android,
|
||||||
title: 'Mobile source code',
|
title: context.l10n.aboutMobileSource,
|
||||||
subtitle: 'github.com/${AppInfo.githubRepo}',
|
subtitle: 'github.com/${AppInfo.githubRepo}',
|
||||||
onTap: () => _launchUrl(AppInfo.githubUrl),
|
onTap: () => _launchUrl(AppInfo.githubUrl),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.computer,
|
icon: Icons.computer,
|
||||||
title: 'PC source code',
|
title: context.l10n.aboutPCSource,
|
||||||
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
|
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
|
||||||
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.bug_report_outlined,
|
icon: Icons.bug_report_outlined,
|
||||||
title: 'Report an issue',
|
title: context.l10n.aboutReportIssue,
|
||||||
subtitle: 'Report any problems you encounter',
|
subtitle: context.l10n.aboutReportIssueSubtitle,
|
||||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.lightbulb_outline,
|
icon: Icons.lightbulb_outline,
|
||||||
title: 'Feature request',
|
title: context.l10n.aboutFeatureRequest,
|
||||||
subtitle: 'Suggest new features for the app',
|
subtitle: context.l10n.aboutFeatureRequestSubtitle,
|
||||||
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -166,17 +167,16 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Support section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.aboutSupport),
|
||||||
child: SettingsSectionHeader(title: 'Support'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.coffee_outlined,
|
icon: Icons.coffee_outlined,
|
||||||
title: 'Buy me a coffee',
|
title: context.l10n.aboutBuyMeCoffee,
|
||||||
subtitle: 'Support development on Ko-fi',
|
subtitle: context.l10n.aboutBuyMeCoffeeSubtitle,
|
||||||
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
onTap: () => _launchUrl(AppInfo.kofiUrl),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -184,16 +184,15 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// App info section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.aboutApp),
|
||||||
child: SettingsSectionHeader(title: 'App'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
title: 'Version',
|
title: context.l10n.aboutVersion,
|
||||||
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -201,7 +200,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Copyright
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -216,7 +214,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom padding
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
const SliverToBoxAdapter(child: SizedBox(height: 16)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -226,7 +223,6 @@ class AboutPage extends StatelessWidget {
|
|||||||
|
|
||||||
static Future<void> _launchUrl(String url) async {
|
static Future<void> _launchUrl(String url) async {
|
||||||
final uri = Uri.parse(url);
|
final uri = Uri.parse(url);
|
||||||
// Use inAppBrowserView for reliable URL opening with app chooser
|
|
||||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,8 +245,6 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// App logo
|
|
||||||
// App logo
|
|
||||||
Container(
|
Container(
|
||||||
width: 88,
|
width: 88,
|
||||||
height: 88,
|
height: 88,
|
||||||
@@ -274,7 +268,6 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// App name
|
|
||||||
Text(
|
Text(
|
||||||
AppInfo.appName,
|
AppInfo.appName,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
@@ -282,7 +275,6 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Version badge
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -298,9 +290,8 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// Description
|
|
||||||
Text(
|
Text(
|
||||||
'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.',
|
context.l10n.aboutAppDescription,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -340,7 +331,6 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// GitHub Avatar
|
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
@@ -371,7 +361,6 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Name and description
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -390,7 +379,6 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// GitHub icon
|
|
||||||
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -414,7 +402,140 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Settings item with 40x40 icon area to align with contributor avatars
|
/// Translator data model
|
||||||
|
class _Translator {
|
||||||
|
final String name;
|
||||||
|
final String crowdinUsername;
|
||||||
|
final String language;
|
||||||
|
final String flag;
|
||||||
|
|
||||||
|
const _Translator({
|
||||||
|
required this.name,
|
||||||
|
required this.crowdinUsername,
|
||||||
|
required this.language,
|
||||||
|
required this.flag,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translators section with compact chip-style layout
|
||||||
|
class _TranslatorsSection extends StatelessWidget {
|
||||||
|
const _TranslatorsSection();
|
||||||
|
|
||||||
|
static const List<_Translator> _translators = [
|
||||||
|
_Translator(
|
||||||
|
name: 'Pedro Marcondes',
|
||||||
|
crowdinUsername: 'justapedro',
|
||||||
|
language: 'Portuguese',
|
||||||
|
flag: '🇵🇹',
|
||||||
|
),
|
||||||
|
_Translator(
|
||||||
|
name: 'Credits 125',
|
||||||
|
crowdinUsername: 'credits125',
|
||||||
|
language: 'Spanish',
|
||||||
|
flag: '🇪🇸',
|
||||||
|
),
|
||||||
|
_Translator(
|
||||||
|
name: 'Владислав',
|
||||||
|
crowdinUsername: 'odinokiy_kot',
|
||||||
|
language: 'Russian',
|
||||||
|
flag: '🇷🇺',
|
||||||
|
),
|
||||||
|
_Translator(
|
||||||
|
name: 'Max',
|
||||||
|
crowdinUsername: 'amonoman',
|
||||||
|
language: 'German',
|
||||||
|
flag: '🇩🇪',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
final cardColor = isDark
|
||||||
|
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
|
||||||
|
: colorScheme.surfaceContainerHighest;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cardColor,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _translators.map((translator) => _TranslatorChip(
|
||||||
|
translator: translator,
|
||||||
|
)).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Individual translator chip
|
||||||
|
class _TranslatorChip extends StatelessWidget {
|
||||||
|
final _Translator translator;
|
||||||
|
|
||||||
|
const _TranslatorChip({required this.translator});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _launchCrowdin(translator.crowdinUsername),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 10,
|
||||||
|
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
|
||||||
|
child: Text(
|
||||||
|
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
translator.name,
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
translator.flag,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _launchCrowdin(String username) async {
|
||||||
|
final uri = Uri.parse('https://crowdin.com/profile/$username');
|
||||||
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _AboutSettingsItem extends StatelessWidget {
|
class _AboutSettingsItem extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -445,7 +566,6 @@ class _AboutSettingsItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Icon with 40x40 size to match avatar
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package: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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/theme_provider.dart';
|
import 'package:spotiflac_android/providers/theme_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
@@ -19,7 +21,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -32,12 +33,11 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
flexibleSpace: _AppBarTitle(
|
flexibleSpace: _AppBarTitle(
|
||||||
title: 'Appearance',
|
title: context.l10n.appearanceTitle,
|
||||||
topPadding: topPadding,
|
topPadding: topPadding,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Preview Section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -48,9 +48,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Color section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionColor),
|
||||||
child: SettingsSectionHeader(title: 'Color'),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
@@ -58,8 +57,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.wallpaper,
|
icon: Icons.wallpaper,
|
||||||
title: 'Dynamic Color',
|
title: context.l10n.appearanceDynamicColor,
|
||||||
subtitle: 'Use colors from your wallpaper',
|
subtitle: context.l10n.appearanceDynamicColorSubtitle,
|
||||||
value: themeSettings.useDynamicColor,
|
value: themeSettings.useDynamicColor,
|
||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.read(themeProvider.notifier)
|
.read(themeProvider.notifier)
|
||||||
@@ -78,12 +77,11 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
onColorSelected: (color) =>
|
onColorSelected: (color) =>
|
||||||
ref.read(themeProvider.notifier).setSeedColor(color),
|
ref.read(themeProvider.notifier).setSeedColor(color),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Theme section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
|
||||||
child: SettingsSectionHeader(title: 'Theme'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -96,8 +94,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
if (Theme.of(context).brightness == Brightness.dark)
|
if (Theme.of(context).brightness == Brightness.dark)
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.brightness_2,
|
icon: Icons.brightness_2,
|
||||||
title: 'AMOLED Dark',
|
title: context.l10n.appearanceAmoledDark,
|
||||||
subtitle: 'Pure black background',
|
subtitle: context.l10n.appearanceAmoledDarkSubtitle,
|
||||||
value: themeSettings.useAmoled,
|
value: themeSettings.useAmoled,
|
||||||
onChanged: (value) =>
|
onChanged: (value) =>
|
||||||
ref.read(themeProvider.notifier).setUseAmoled(value),
|
ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||||
@@ -107,9 +105,24 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Layout section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionLanguage),
|
||||||
child: SettingsSectionHeader(title: 'Layout'),
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
_LanguageSelector(
|
||||||
|
currentLocale: settings.locale,
|
||||||
|
onChanged: (locale) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setLocale(locale),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: context.l10n.sectionLayout),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -124,7 +137,6 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Fill remaining for scroll
|
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
hasScrollBody: false,
|
hasScrollBody: false,
|
||||||
child: SizedBox(height: 32),
|
child: SizedBox(height: 32),
|
||||||
@@ -155,7 +167,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Decorative background blobs
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: -50,
|
top: -50,
|
||||||
right: -50,
|
right: -50,
|
||||||
@@ -181,7 +192,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Foreground "fake UI"
|
|
||||||
Center(
|
Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -200,7 +210,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Fake Album Art
|
|
||||||
Container(
|
Container(
|
||||||
width: 108,
|
width: 108,
|
||||||
height: 108,
|
height: 108,
|
||||||
@@ -216,7 +225,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
// Fake Text Info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -269,7 +277,6 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Label badge
|
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 12,
|
bottom: 12,
|
||||||
right: 12,
|
right: 12,
|
||||||
@@ -283,7 +290,7 @@ class _ThemePreviewCard extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
isDark ? 'Dark Mode' : 'Light Mode',
|
isDark ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -451,21 +458,21 @@ class _ThemeModeSelector extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ThemeModeChip(
|
_ThemeModeChip(
|
||||||
icon: Icons.brightness_auto,
|
icon: Icons.brightness_auto,
|
||||||
label: 'System',
|
label: context.l10n.appearanceThemeSystem,
|
||||||
isSelected: currentMode == ThemeMode.system,
|
isSelected: currentMode == ThemeMode.system,
|
||||||
onTap: () => onChanged(ThemeMode.system),
|
onTap: () => onChanged(ThemeMode.system),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ThemeModeChip(
|
_ThemeModeChip(
|
||||||
icon: Icons.light_mode,
|
icon: Icons.light_mode,
|
||||||
label: 'Light',
|
label: context.l10n.appearanceThemeLight,
|
||||||
isSelected: currentMode == ThemeMode.light,
|
isSelected: currentMode == ThemeMode.light,
|
||||||
onTap: () => onChanged(ThemeMode.light),
|
onTap: () => onChanged(ThemeMode.light),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ThemeModeChip(
|
_ThemeModeChip(
|
||||||
icon: Icons.dark_mode,
|
icon: Icons.dark_mode,
|
||||||
label: 'Dark',
|
label: context.l10n.appearanceThemeDark,
|
||||||
isSelected: currentMode == ThemeMode.dark,
|
isSelected: currentMode == ThemeMode.dark,
|
||||||
onTap: () => onChanged(ThemeMode.dark),
|
onTap: () => onChanged(ThemeMode.dark),
|
||||||
),
|
),
|
||||||
@@ -491,10 +498,7 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Unselected chips need contrast with card background
|
|
||||||
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
|
||||||
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(
|
? Color.alphaBlend(
|
||||||
Colors.white.withValues(alpha: 0.05),
|
Colors.white.withValues(alpha: 0.05),
|
||||||
@@ -575,7 +579,7 @@ class _HistoryViewSelector extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'History View',
|
context.l10n.appearanceHistoryView,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -585,14 +589,14 @@ class _HistoryViewSelector extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_ViewModeChip(
|
_ViewModeChip(
|
||||||
icon: Icons.view_list,
|
icon: Icons.view_list,
|
||||||
label: 'List',
|
label: context.l10n.appearanceHistoryViewList,
|
||||||
isSelected: currentMode == 'list',
|
isSelected: currentMode == 'list',
|
||||||
onTap: () => onChanged('list'),
|
onTap: () => onChanged('list'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ViewModeChip(
|
_ViewModeChip(
|
||||||
icon: Icons.grid_view,
|
icon: Icons.grid_view,
|
||||||
label: 'Grid',
|
label: context.l10n.appearanceHistoryViewGrid,
|
||||||
isSelected: currentMode == 'grid',
|
isSelected: currentMode == 'grid',
|
||||||
onTap: () => onChanged('grid'),
|
onTap: () => onChanged('grid'),
|
||||||
),
|
),
|
||||||
@@ -621,7 +625,6 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Unselected chips need contrast with card background
|
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(
|
? Color.alphaBlend(
|
||||||
Colors.white.withValues(alpha: 0.05),
|
Colors.white.withValues(alpha: 0.05),
|
||||||
@@ -682,3 +685,131 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _LanguageSelector extends StatelessWidget {
|
||||||
|
final String currentLocale;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
const _LanguageSelector({
|
||||||
|
required this.currentLocale,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
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),
|
||||||
|
('es_ES', 'Español (España)', 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),
|
||||||
|
('pt_PT', 'Português (Portugal)', Icons.language),
|
||||||
|
('ru', 'Русский', Icons.language),
|
||||||
|
('zh', '简体中文', Icons.language),
|
||||||
|
('zh_CN', '简体中文 (中国)', 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) {
|
||||||
|
if (lang.$1 == 'system') return true;
|
||||||
|
return filteredLocaleCodes.contains(lang.$1);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getLanguageName(String code) {
|
||||||
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path_provider/path_provider.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/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
@@ -10,7 +11,6 @@ import 'package:spotiflac_android/widgets/settings_group.dart';
|
|||||||
class DownloadSettingsPage extends ConsumerWidget {
|
class DownloadSettingsPage extends ConsumerWidget {
|
||||||
const DownloadSettingsPage({super.key});
|
const DownloadSettingsPage({super.key});
|
||||||
|
|
||||||
// Built-in services that support quality options
|
|
||||||
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -19,7 +19,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final topPadding = MediaQuery.of(context).padding.top;
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
// Check if current service is built-in (supports quality options)
|
|
||||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
@@ -27,7 +26,6 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -55,7 +53,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
bottom: 16,
|
bottom: 16,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Download',
|
context.l10n.downloadTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -67,9 +65,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Service section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionService),
|
||||||
child: SettingsSectionHeader(title: 'Service'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -84,52 +81,71 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Quality section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionAudioQuality),
|
||||||
child: SettingsSectionHeader(title: 'Audio Quality'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.tune,
|
icon: Icons.tune,
|
||||||
title: 'Ask Before Download',
|
title: context.l10n.downloadAskBeforeDownload,
|
||||||
subtitle: isBuiltInService
|
subtitle: isBuiltInService
|
||||||
? 'Choose quality for each download'
|
? context.l10n.downloadAskQualitySubtitle
|
||||||
: 'Select a built-in service to enable',
|
: 'Select a built-in service to enable',
|
||||||
value: settings.askQualityBeforeDownload,
|
value: settings.askQualityBeforeDownload,
|
||||||
// Not selected visually if extension is active
|
|
||||||
enabled: isBuiltInService,
|
enabled: isBuiltInService,
|
||||||
onChanged: (value) => ref
|
onChanged: (value) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAskQualityBeforeDownload(value),
|
.setAskQualityBeforeDownload(value),
|
||||||
),
|
),
|
||||||
|
SettingsSwitchItem(
|
||||||
|
icon: Icons.audiotrack,
|
||||||
|
title: context.l10n.enableMp3Option,
|
||||||
|
subtitle: settings.enableMp3Option
|
||||||
|
? context.l10n.enableMp3OptionSubtitleOn
|
||||||
|
: context.l10n.enableMp3OptionSubtitleOff,
|
||||||
|
value: settings.enableMp3Option,
|
||||||
|
onChanged: (value) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setEnableMp3Option(value),
|
||||||
|
),
|
||||||
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: 'FLAC Lossless',
|
title: context.l10n.qualityFlacLossless,
|
||||||
subtitle: '16-bit / 44.1kHz',
|
subtitle: context.l10n.qualityFlacLosslessSubtitle,
|
||||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('LOSSLESS'),
|
.setAudioQuality('LOSSLESS'),
|
||||||
),
|
),
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: 'Hi-Res FLAC',
|
title: context.l10n.qualityHiResFlac,
|
||||||
subtitle: '24-bit / up to 96kHz',
|
subtitle: context.l10n.qualityHiResFlacSubtitle,
|
||||||
isSelected: settings.audioQuality == 'HI_RES',
|
isSelected: settings.audioQuality == 'HI_RES',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('HI_RES'),
|
.setAudioQuality('HI_RES'),
|
||||||
),
|
),
|
||||||
_QualityOption(
|
_QualityOption(
|
||||||
title: 'Hi-Res FLAC Max',
|
title: context.l10n.qualityHiResFlacMax,
|
||||||
subtitle: '24-bit / up to 192kHz',
|
subtitle: context.l10n.qualityHiResFlacMaxSubtitle,
|
||||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
.setAudioQuality('HI_RES_LOSSLESS'),
|
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||||
showDivider: false,
|
showDivider: settings.enableMp3Option,
|
||||||
),
|
),
|
||||||
|
if (settings.enableMp3Option)
|
||||||
|
_QualityOption(
|
||||||
|
title: context.l10n.qualityMp3,
|
||||||
|
subtitle: context.l10n.qualityMp3Subtitle,
|
||||||
|
isSelected: settings.audioQuality == 'MP3',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('MP3'),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
if (!isBuiltInService) ...[
|
if (!isBuiltInService) ...[
|
||||||
Padding(
|
Padding(
|
||||||
@@ -158,16 +174,15 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// File settings section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionFileSettings),
|
||||||
child: SettingsSectionHeader(title: 'File Settings'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.text_fields,
|
icon: Icons.text_fields,
|
||||||
title: 'Filename Format',
|
title: context.l10n.downloadFilenameFormat,
|
||||||
subtitle: settings.filenameFormat,
|
subtitle: settings.filenameFormat,
|
||||||
onTap: () => _showFormatEditor(
|
onTap: () => _showFormatEditor(
|
||||||
context,
|
context,
|
||||||
@@ -177,17 +192,17 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: 'Download Directory',
|
title: context.l10n.downloadDirectory,
|
||||||
subtitle: settings.downloadDirectory.isEmpty
|
subtitle: settings.downloadDirectory.isEmpty
|
||||||
? (Platform.isIOS
|
? (Platform.isIOS
|
||||||
? 'App Documents Folder'
|
? context.l10n.setupAppDocumentsFolder
|
||||||
: 'Music/SpotiFLAC')
|
: 'Music/SpotiFLAC')
|
||||||
: settings.downloadDirectory,
|
: settings.downloadDirectory,
|
||||||
onTap: () => _pickDirectory(context, ref),
|
onTap: () => _pickDirectory(context, ref),
|
||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.library_music_outlined,
|
icon: Icons.library_music_outlined,
|
||||||
title: 'Separate Singles Folder',
|
title: context.l10n.downloadSeparateSinglesFolder,
|
||||||
subtitle: settings.separateSingles
|
subtitle: settings.separateSingles
|
||||||
? 'Albums/ and Singles/ folders'
|
? 'Albums/ and Singles/ folders'
|
||||||
: 'All files in same structure',
|
: 'All files in same structure',
|
||||||
@@ -199,10 +214,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
if (settings.separateSingles)
|
if (settings.separateSingles)
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: 'Album Folder Structure',
|
title: context.l10n.downloadAlbumFolderStructure,
|
||||||
subtitle: settings.albumFolderStructure == 'album_only'
|
subtitle: _getAlbumFolderStructureLabel(settings.albumFolderStructure),
|
||||||
? 'Albums/Album Name/'
|
|
||||||
: 'Albums/Artist/Album Name/',
|
|
||||||
onTap: () => _showAlbumFolderStructurePicker(
|
onTap: () => _showAlbumFolderStructurePicker(
|
||||||
context,
|
context,
|
||||||
ref,
|
ref,
|
||||||
@@ -212,7 +225,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
if (!settings.separateSingles)
|
if (!settings.separateSingles)
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.create_new_folder_outlined,
|
icon: Icons.create_new_folder_outlined,
|
||||||
title: 'Folder Organization',
|
title: context.l10n.downloadFolderOrganization,
|
||||||
subtitle: _getFolderOrganizationLabel(
|
subtitle: _getFolderOrganizationLabel(
|
||||||
settings.folderOrganization,
|
settings.folderOrganization,
|
||||||
),
|
),
|
||||||
@@ -234,6 +247,19 @@ 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) {
|
void _showAlbumFolderStructurePicker(BuildContext context, WidgetRef ref, String current) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@@ -243,24 +269,44 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.folder_outlined),
|
leading: const Icon(Icons.folder_outlined),
|
||||||
title: const Text('Artist / Album'),
|
title: Text(context.l10n.albumFolderArtistAlbum),
|
||||||
subtitle: const Text('Albums/Artist Name/Album Name/'),
|
subtitle: Text(context.l10n.albumFolderArtistAlbumSubtitle),
|
||||||
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
|
trailing: current == 'artist_album' ? const Icon(Icons.check) : null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
ref.read(settingsProvider.notifier).setAlbumFolderStructure('artist_album');
|
||||||
Navigator.pop(context);
|
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(
|
ListTile(
|
||||||
leading: const Icon(Icons.album_outlined),
|
leading: const Icon(Icons.album_outlined),
|
||||||
title: const Text('Album Only'),
|
title: Text(context.l10n.albumFolderAlbumOnly),
|
||||||
subtitle: const Text('Albums/Album Name/'),
|
subtitle: Text(context.l10n.albumFolderAlbumOnlySubtitle),
|
||||||
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
|
trailing: current == 'album_only' ? const Icon(Icons.check) : null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
ref.read(settingsProvider.notifier).setAlbumFolderStructure('album_only');
|
||||||
Navigator.pop(context);
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -289,11 +335,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
String insertion = tag;
|
String insertion = tag;
|
||||||
if (start > 0) {
|
if (start > 0) {
|
||||||
final before = text.substring(0, start);
|
final before = text.substring(0, start);
|
||||||
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
|
|
||||||
if (!before.trim().endsWith('-')) {
|
if (!before.trim().endsWith('-')) {
|
||||||
insertion = ' - $tag';
|
insertion = ' - $tag';
|
||||||
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||||
// If ends with '-' but no space, add space
|
|
||||||
insertion = ' $tag';
|
insertion = ' $tag';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -336,7 +380,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Filename Format',
|
context.l10n.filenameFormat,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -402,7 +446,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
@@ -410,7 +454,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -429,7 +473,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text('Save Format'),
|
child: Text(context.l10n.dialogSave),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -446,10 +490,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
|
|
||||||
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
// iOS: Show options dialog
|
|
||||||
_showIOSDirectoryOptions(context, ref);
|
_showIOSDirectoryOptions(context, ref);
|
||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
@@ -473,7 +515,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Download Location',
|
context.l10n.setupDownloadLocationTitle,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
@@ -482,7 +524,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
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(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -490,8 +532,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||||
title: const Text('App Documents Folder'),
|
title: Text(context.l10n.setupAppDocumentsFolder),
|
||||||
subtitle: const Text('Recommended - accessible via Files app'),
|
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
|
||||||
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final dir = await getApplicationDocumentsDirectory();
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
@@ -503,8 +545,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
title: const Text('Choose from Files'),
|
title: Text(context.l10n.setupChooseFromFiles),
|
||||||
subtitle: const Text('Select iCloud or other location'),
|
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
// Note: iOS requires folder to have at least one file to be selectable
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
@@ -534,7 +576,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -558,7 +600,7 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
case 'album':
|
case 'album':
|
||||||
return 'By Album';
|
return 'By Album';
|
||||||
case 'artist_album':
|
case 'artist_album':
|
||||||
return 'By Artist & Album';
|
return 'Artist/Album';
|
||||||
default:
|
default:
|
||||||
return 'None';
|
return 'None';
|
||||||
}
|
}
|
||||||
@@ -598,15 +640,15 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Organize downloaded files into folders',
|
context.l10n.folderOrganizationDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'None',
|
title: context.l10n.folderOrganizationNone,
|
||||||
subtitle: 'All files in download folder',
|
subtitle: context.l10n.folderOrganizationNoneSubtitle,
|
||||||
example: 'SpotiFLAC/Track.flac',
|
example: 'SpotiFLAC/Track.flac',
|
||||||
isSelected: current == 'none',
|
isSelected: current == 'none',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -615,8 +657,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist',
|
title: context.l10n.folderOrganizationByArtist,
|
||||||
subtitle: 'Separate folder for each artist',
|
subtitle: context.l10n.folderOrganizationByArtistSubtitle,
|
||||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||||
isSelected: current == 'artist',
|
isSelected: current == 'artist',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -625,8 +667,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Album',
|
title: context.l10n.folderOrganizationByAlbum,
|
||||||
subtitle: 'Separate folder for each album',
|
subtitle: context.l10n.folderOrganizationByAlbumSubtitle,
|
||||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||||
isSelected: current == 'album',
|
isSelected: current == 'album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -635,8 +677,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist & Album',
|
title: context.l10n.folderOrganizationByArtistAlbum,
|
||||||
subtitle: 'Nested folders for artist and album',
|
subtitle: context.l10n.folderOrganizationByArtistAlbumSubtitle,
|
||||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||||
isSelected: current == 'artist_album',
|
isSelected: current == 'artist_album',
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -665,18 +707,15 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
|
|
||||||
// Get enabled extension download providers
|
|
||||||
final extensionProviders = extState.extensions
|
final extensionProviders = extState.extensions
|
||||||
.where((e) => e.enabled && e.hasDownloadProvider)
|
.where((e) => e.enabled && e.hasDownloadProvider)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Check if current service is an extension that's now disabled
|
|
||||||
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
final isExtensionService = !['tidal', 'qobuz', 'amazon'].contains(currentService);
|
||||||
final isCurrentExtensionEnabled = isExtensionService
|
final isCurrentExtensionEnabled = isExtensionService
|
||||||
? extensionProviders.any((e) => e.id == currentService)
|
? extensionProviders.any((e) => e.id == currentService)
|
||||||
: true;
|
: true;
|
||||||
|
|
||||||
// If current extension is disabled, show it as not selected
|
|
||||||
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
final effectiveService = isCurrentExtensionEnabled ? currentService : '';
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -707,7 +746,6 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// Show extension download providers if any
|
|
||||||
if (extensionProviders.isNotEmpty) ...[
|
if (extensionProviders.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
@@ -723,7 +761,6 @@ class _ServiceSelector extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
// Fill remaining space if less than 3 extensions
|
|
||||||
for (int i = extensionProviders.length; i < 3; i++) ...[
|
for (int i = extensionProviders.length; i < 3; i++) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Expanded(child: SizedBox()),
|
const Expanded(child: SizedBox()),
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
||||||
@@ -61,7 +63,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -97,7 +98,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Extension Info Card
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -186,12 +186,12 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_InfoRow(label: 'Author', value: extension.author),
|
_InfoRow(label: context.l10n.extensionAuthor, value: extension.author),
|
||||||
_InfoRow(label: 'ID', value: extension.id),
|
_InfoRow(label: context.l10n.extensionId, value: extension.id),
|
||||||
_InfoRow(label: 'Version', value: 'v${extension.version}'),
|
_InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''),
|
||||||
if (hasError && extension.errorMessage != null)
|
if (hasError && extension.errorMessage != null)
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
label: 'Error',
|
label: context.l10n.extensionError,
|
||||||
value: extension.errorMessage!,
|
value: extension.errorMessage!,
|
||||||
isError: true,
|
isError: true,
|
||||||
),
|
),
|
||||||
@@ -201,51 +201,50 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Capabilities
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.extensionCapabilities),
|
||||||
child: SettingsSectionHeader(title: 'Capabilities'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.search,
|
icon: Icons.search,
|
||||||
title: 'Metadata Provider',
|
title: context.l10n.extensionMetadataProvider,
|
||||||
enabled: extension.hasMetadataProvider,
|
enabled: extension.hasMetadataProvider,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.download,
|
icon: Icons.download,
|
||||||
title: 'Download Provider',
|
title: context.l10n.extensionDownloadProvider,
|
||||||
enabled: extension.hasDownloadProvider,
|
enabled: extension.hasDownloadProvider,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.manage_search,
|
icon: Icons.manage_search,
|
||||||
title: 'Custom Search',
|
title: context.l10n.extensionsSearchProvider,
|
||||||
enabled: extension.hasCustomSearch,
|
enabled: extension.hasCustomSearch,
|
||||||
subtitle: extension.searchBehavior?.placeholder,
|
subtitle: extension.searchBehavior?.placeholder,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.compare_arrows,
|
icon: Icons.compare_arrows,
|
||||||
title: 'Custom Track Matching',
|
title: context.l10n.extensionCustomTrackMatching,
|
||||||
enabled: extension.hasCustomMatching,
|
enabled: extension.hasCustomMatching,
|
||||||
subtitle: extension.trackMatching?.strategy != null
|
subtitle: extension.trackMatching?.strategy != null
|
||||||
? 'Strategy: ${extension.trackMatching!.strategy}'
|
? context.l10n.extensionStrategy(extension.trackMatching!.strategy!)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.auto_fix_high,
|
icon: Icons.auto_fix_high,
|
||||||
title: 'Post-Processing',
|
title: context.l10n.extensionPostProcessing,
|
||||||
enabled: extension.hasPostProcessing,
|
enabled: extension.hasPostProcessing,
|
||||||
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
|
subtitle: extension.postProcessing?.hooks.isNotEmpty == true
|
||||||
? '${extension.postProcessing!.hooks.length} hook(s) available'
|
? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
_CapabilityItem(
|
_CapabilityItem(
|
||||||
icon: Icons.link,
|
icon: Icons.link,
|
||||||
title: 'URL Handler',
|
title: context.l10n.extensionUrlHandler,
|
||||||
enabled: extension.hasURLHandler,
|
enabled: extension.hasURLHandler,
|
||||||
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
|
subtitle: extension.urlHandler?.patterns.isNotEmpty == true
|
||||||
? '${extension.urlHandler!.patterns.length} pattern(s)'
|
? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length)
|
||||||
: null,
|
: null,
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
@@ -253,12 +252,9 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// URL Handler Section (if extension handles URLs)
|
|
||||||
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
|
if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'URL Handler'),
|
child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -271,10 +267,9 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Quality Options Section (for download providers)
|
|
||||||
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
|
if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Quality Options'),
|
child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -290,10 +285,9 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Post-Processing Hooks (if available)
|
|
||||||
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
|
if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Post-Processing Hooks'),
|
child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -309,10 +303,9 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Permissions
|
|
||||||
if (extension.permissions.isNotEmpty) ...[
|
if (extension.permissions.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Permissions'),
|
child: SettingsSectionHeader(title: context.l10n.extensionPermissions),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -328,10 +321,9 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Settings
|
|
||||||
if (extension.settings.isNotEmpty) ...[
|
if (extension.settings.isNotEmpty) ...[
|
||||||
const SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(title: 'Settings'),
|
child: SettingsSectionHeader(title: context.l10n.extensionSettings),
|
||||||
),
|
),
|
||||||
if (_isLoadingSettings)
|
if (_isLoadingSettings)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
@@ -351,20 +343,20 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
value: _settings[setting.key] ?? setting.defaultValue,
|
value: _settings[setting.key] ?? setting.defaultValue,
|
||||||
showDivider: index < extension.settings.length - 1,
|
showDivider: index < extension.settings.length - 1,
|
||||||
onChanged: (value) => _updateSetting(setting.key, value),
|
onChanged: (value) => _updateSetting(setting.key, value),
|
||||||
|
extensionId: widget.extensionId,
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Remove button
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: () => _confirmRemove(context),
|
onPressed: () => _confirmRemove(context),
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
label: const Text('Remove Extension'),
|
label: Text(context.l10n.extensionRemoveButton),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: colorScheme.error,
|
foregroundColor: colorScheme.error,
|
||||||
side: BorderSide(color: colorScheme.error),
|
side: BorderSide(color: colorScheme.error),
|
||||||
@@ -398,22 +390,21 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Remove Extension'),
|
title: Text(context.l10n.dialogRemoveExtension),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'Are you sure you want to remove this extension? '
|
context.l10n.dialogRemoveExtensionMessage,
|
||||||
'This action cannot be undone.',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: colorScheme.error,
|
backgroundColor: colorScheme.error,
|
||||||
),
|
),
|
||||||
child: const Text('Remove'),
|
child: Text(context.l10n.dialogRemove),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -424,7 +415,6 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||||||
.read(extensionProvider.notifier)
|
.read(extensionProvider.notifier)
|
||||||
.removeExtension(widget.extensionId);
|
.removeExtension(widget.extensionId);
|
||||||
if (success && mounted) {
|
if (success && mounted) {
|
||||||
// Refresh store to update isInstalled status
|
|
||||||
ref.read(storeProvider.notifier).refresh();
|
ref.read(storeProvider.notifier).refresh();
|
||||||
Navigator.pop(this.context);
|
Navigator.pop(this.context);
|
||||||
}
|
}
|
||||||
@@ -557,7 +547,6 @@ class _PermissionItem extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Parse permission to get icon and description
|
|
||||||
IconData icon = Icons.security;
|
IconData icon = Icons.security;
|
||||||
String description = permission;
|
String description = permission;
|
||||||
|
|
||||||
@@ -600,41 +589,62 @@ class _PermissionItem extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingItem extends StatelessWidget {
|
class _SettingItem extends StatefulWidget {
|
||||||
final ExtensionSetting setting;
|
final ExtensionSetting setting;
|
||||||
final dynamic value;
|
final dynamic value;
|
||||||
final bool showDivider;
|
final bool showDivider;
|
||||||
final ValueChanged<dynamic> onChanged;
|
final ValueChanged<dynamic> onChanged;
|
||||||
|
final String extensionId;
|
||||||
|
|
||||||
const _SettingItem({
|
const _SettingItem({
|
||||||
required this.setting,
|
required this.setting,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
|
required this.extensionId,
|
||||||
this.showDivider = true,
|
this.showDivider = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_SettingItem> createState() => _SettingItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingItemState extends State<_SettingItem> {
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
Widget trailing;
|
Widget trailing;
|
||||||
switch (setting.type) {
|
switch (widget.setting.type) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
trailing = Switch(
|
trailing = Switch(
|
||||||
value: value as bool? ?? false,
|
value: widget.value as bool? ?? false,
|
||||||
onChanged: onChanged,
|
onChanged: widget.onChanged,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'select':
|
case 'select':
|
||||||
trailing = DropdownButton<String>(
|
trailing = DropdownButton<String>(
|
||||||
value: value as String?,
|
value: widget.value as String?,
|
||||||
items: setting.options?.map((opt) {
|
items: widget.setting.options?.map((opt) {
|
||||||
return DropdownMenuItem(value: opt, child: Text(opt));
|
return DropdownMenuItem(value: opt, child: Text(opt));
|
||||||
}).toList(),
|
}).toList(),
|
||||||
onChanged: onChanged,
|
onChanged: widget.onChanged,
|
||||||
underline: const SizedBox(),
|
underline: const SizedBox(),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'button':
|
||||||
|
trailing = _isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: FilledButton.tonal(
|
||||||
|
onPressed: () => _invokeAction(context),
|
||||||
|
child: Text(widget.setting.label),
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
trailing = Icon(
|
trailing = Icon(
|
||||||
Icons.chevron_right,
|
Icons.chevron_right,
|
||||||
@@ -642,11 +652,52 @@ class _SettingItem extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For button type, show a different layout
|
||||||
|
if (widget.setting.type == 'button') {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (widget.setting.description != null) ...[
|
||||||
|
Text(
|
||||||
|
widget.setting.description!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 16,
|
||||||
|
endIndent: 16,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: setting.type == 'string' || setting.type == 'number'
|
onTap: widget.setting.type == 'string' || widget.setting.type == 'number'
|
||||||
? () => _showEditDialog(context)
|
? () => _showEditDialog(context)
|
||||||
: null,
|
: null,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -658,22 +709,22 @@ class _SettingItem extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
setting.label,
|
widget.setting.label,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
if (setting.description != null) ...[
|
if (widget.setting.description != null) ...[
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
setting.description!,
|
widget.setting.description!,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (setting.type == 'string' || setting.type == 'number') ...[
|
if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value?.toString() ?? 'Not set',
|
widget.value?.toString() ?? 'Not set',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
),
|
),
|
||||||
@@ -687,7 +738,7 @@ class _SettingItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showDivider)
|
if (widget.showDivider)
|
||||||
Divider(
|
Divider(
|
||||||
height: 1,
|
height: 1,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
@@ -699,21 +750,66 @@ class _SettingItem extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _invokeAction(BuildContext context) async {
|
||||||
|
if (widget.setting.action == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No action defined for this button')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await PlatformBridge.invokeExtensionAction(
|
||||||
|
widget.extensionId,
|
||||||
|
widget.setting.action!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
final success = result['success'] as bool? ?? false;
|
||||||
|
if (!success) {
|
||||||
|
final error = result['error'] as String? ?? 'Action failed';
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(error)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final message = result['message'] as String?;
|
||||||
|
if (message != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Error: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showEditDialog(BuildContext context) {
|
void _showEditDialog(BuildContext context) {
|
||||||
final controller = TextEditingController(text: value?.toString() ?? '');
|
final controller = TextEditingController(text: widget.value?.toString() ?? '');
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(setting.label),
|
title: Text(widget.setting.label),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
keyboardType: setting.type == 'number'
|
keyboardType: widget.setting.type == 'number'
|
||||||
? TextInputType.number
|
? TextInputType.number
|
||||||
: TextInputType.text,
|
: TextInputType.text,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: setting.description ?? 'Enter value',
|
hintText: widget.setting.description ?? 'Enter value',
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
@@ -725,17 +821,17 @@ class _SettingItem extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final newValue = setting.type == 'number'
|
final newValue = widget.setting.type == 'number'
|
||||||
? num.tryParse(controller.text)
|
? num.tryParse(controller.text)
|
||||||
: controller.text;
|
: controller.text;
|
||||||
onChanged(newValue);
|
widget.onChanged(newValue);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text('Save'),
|
child: Text(context.l10n.dialogSave),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:path_provider/path_provider.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/extension_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||||
@@ -31,7 +32,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
final extensionsDir = '${appDir.path}/extensions';
|
final extensionsDir = '${appDir.path}/extensions';
|
||||||
final dataDir = '${appDir.path}/extension_data';
|
final dataDir = '${appDir.path}/extension_data';
|
||||||
|
|
||||||
// Create directories if they don't exist
|
|
||||||
await Directory(extensionsDir).create(recursive: true);
|
await Directory(extensionsDir).create(recursive: true);
|
||||||
await Directory(dataDir).create(recursive: true);
|
await Directory(dataDir).create(recursive: true);
|
||||||
|
|
||||||
@@ -50,7 +50,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -74,7 +73,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Extensions',
|
context.l10n.extensionsTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio),
|
fontSize: 20 + (8 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -86,7 +85,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Loading indicator
|
|
||||||
if (extState.isLoading)
|
if (extState.isLoading)
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -95,7 +93,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error message
|
|
||||||
if (extState.error != null)
|
if (extState.error != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -122,9 +119,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Provider Priority
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.extensionsProviderPrioritySection),
|
||||||
child: SettingsSectionHeader(title: 'Provider Priority'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -136,9 +132,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Installed Extensions
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.extensionsInstalledSection),
|
||||||
child: SettingsSectionHeader(title: 'Installed Extensions'),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
if (extState.extensions.isEmpty && !extState.isLoading)
|
if (extState.extensions.isEmpty && !extState.isLoading)
|
||||||
@@ -160,14 +155,14 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'No extensions installed',
|
context.l10n.extensionsNoExtensions,
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Install .spotiflac-ext files to add new providers',
|
context.l10n.extensionsNoExtensionsSubtitle,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -202,14 +197,13 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Install button
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: FilledButton.icon(
|
child: FilledButton.icon(
|
||||||
onPressed: _installExtension,
|
onPressed: _installExtension,
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Install Extension'),
|
label: Text(context.l10n.extensionsInstallButton),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -220,7 +214,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Info section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||||
@@ -236,8 +229,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Extensions can add new metadata and download providers. '
|
context.l10n.extensionsInfoTip,
|
||||||
'Only install extensions from trusted sources.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -266,8 +258,8 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
if (!file.path!.endsWith('.spotiflac-ext')) {
|
if (!file.path!.endsWith('.spotiflac-ext')) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Please select a .spotiflac-ext file'),
|
content: Text(context.l10n.snackbarSelectExtFile),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -282,13 +274,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
String message;
|
String message;
|
||||||
if (success) {
|
if (success) {
|
||||||
message = 'Extension installed successfully';
|
message = context.l10n.extensionsInstalledSuccess;
|
||||||
} else {
|
} else {
|
||||||
// Parse friendly error message
|
|
||||||
message = _getFriendlyErrorMessage(extState.error);
|
message = _getFriendlyErrorMessage(extState.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the error from state to avoid showing it twice (in error container)
|
|
||||||
ref.read(extensionProvider.notifier).clearError();
|
ref.read(extensionProvider.notifier).clearError();
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -305,15 +295,11 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
|
|
||||||
String message = error;
|
String message = error;
|
||||||
|
|
||||||
// Remove PlatformException wrapper if present
|
|
||||||
// Format: PlatformException(ERROR, actual message, null, null)
|
|
||||||
if (message.contains('PlatformException')) {
|
if (message.contains('PlatformException')) {
|
||||||
// Try to extract the actual error message
|
|
||||||
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
|
final match = RegExp(r'PlatformException\([^,]+,\s*([^,]+(?:,[^,]+)?),').firstMatch(message);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
message = match.group(1)?.trim() ?? message;
|
message = match.group(1)?.trim() ?? message;
|
||||||
} else {
|
} else {
|
||||||
// Fallback: try simpler extraction
|
|
||||||
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
|
final simpleMatch = RegExp(r'PlatformException\([^,]+,\s*(.+?),\s*null').firstMatch(message);
|
||||||
if (simpleMatch != null) {
|
if (simpleMatch != null) {
|
||||||
message = simpleMatch.group(1)?.trim() ?? message;
|
message = simpleMatch.group(1)?.trim() ?? message;
|
||||||
@@ -321,7 +307,6 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up any remaining artifacts
|
|
||||||
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
|
message = message.replaceAll(RegExp(r',\s*null\s*,\s*null\)?$'), '');
|
||||||
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
|
message = message.replaceAll(RegExp(r'^\s*,\s*'), '');
|
||||||
|
|
||||||
@@ -356,7 +341,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Extension icon
|
|
||||||
Container(
|
Container(
|
||||||
width: 44,
|
width: 44,
|
||||||
height: 44,
|
height: 44,
|
||||||
@@ -390,7 +374,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Extension info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -404,8 +387,8 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
hasError
|
hasError
|
||||||
? extension.errorMessage ?? 'Error loading extension'
|
? extension.errorMessage ?? context.l10n.extensionsErrorLoading
|
||||||
: 'v${extension.version} by ${extension.author}',
|
: 'v${extension.version} ${context.l10n.extensionsAuthor(extension.author)}',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: hasError
|
color: hasError
|
||||||
? colorScheme.error
|
? colorScheme.error
|
||||||
@@ -415,7 +398,6 @@ class _ExtensionItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Toggle switch
|
|
||||||
Switch(
|
Switch(
|
||||||
value: extension.enabled,
|
value: extension.enabled,
|
||||||
onChanged: hasError ? null : onToggle,
|
onChanged: hasError ? null : onToggle,
|
||||||
@@ -445,7 +427,6 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
|||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Check if any extension has download provider
|
|
||||||
final hasDownloadExtensions = extState.extensions
|
final hasDownloadExtensions = extState.extensions
|
||||||
.any((e) => e.enabled && e.hasDownloadProvider);
|
.any((e) => e.enabled && e.hasDownloadProvider);
|
||||||
|
|
||||||
@@ -474,7 +455,7 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Download Priority',
|
context.l10n.extensionsDownloadPriority,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: hasDownloadExtensions
|
color: hasDownloadExtensions
|
||||||
? null
|
? null
|
||||||
@@ -484,8 +465,8 @@ class _DownloadPriorityItem extends ConsumerWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
hasDownloadExtensions
|
hasDownloadExtensions
|
||||||
? 'Set download service order'
|
? context.l10n.extensionsDownloadPrioritySubtitle
|
||||||
: 'No extensions with download provider',
|
: context.l10n.extensionsNoDownloadProvider,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -514,7 +495,6 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
|||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Check if any extension has metadata provider
|
|
||||||
final hasMetadataExtensions = extState.extensions
|
final hasMetadataExtensions = extState.extensions
|
||||||
.any((e) => e.enabled && e.hasMetadataProvider);
|
.any((e) => e.enabled && e.hasMetadataProvider);
|
||||||
|
|
||||||
@@ -543,7 +523,7 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Metadata Priority',
|
context.l10n.extensionsMetadataPriority,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: hasMetadataExtensions
|
color: hasMetadataExtensions
|
||||||
? null
|
? null
|
||||||
@@ -553,8 +533,8 @@ class _MetadataPriorityItem extends ConsumerWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
hasMetadataExtensions
|
hasMetadataExtensions
|
||||||
? 'Set search & metadata source order'
|
? context.l10n.extensionsMetadataPrioritySubtitle
|
||||||
: 'No extensions with metadata provider',
|
: context.l10n.extensionsNoMetadataProvider,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -584,13 +564,11 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Get extensions with custom search
|
|
||||||
final searchProviders = extState.extensions
|
final searchProviders = extState.extensions
|
||||||
.where((e) => e.enabled && e.hasCustomSearch)
|
.where((e) => e.enabled && e.hasCustomSearch)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Get current provider name
|
String currentProviderName = context.l10n.extensionDefaultProvider;
|
||||||
String currentProviderName = 'Default (Deezer/Spotify)';
|
|
||||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||||
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
final ext = searchProviders.where((e) => e.id == settings.searchProvider).firstOrNull;
|
||||||
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
currentProviderName = ext?.displayName ?? settings.searchProvider!;
|
||||||
@@ -619,7 +597,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Search Provider',
|
context.l10n.extensionsSearchProvider,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: searchProviders.isEmpty
|
color: searchProviders.isEmpty
|
||||||
? colorScheme.outline
|
? colorScheme.outline
|
||||||
@@ -629,7 +607,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
searchProviders.isEmpty
|
searchProviders.isEmpty
|
||||||
? 'No extensions with custom search'
|
? context.l10n.extensionsNoCustomSearch
|
||||||
: currentProviderName,
|
: currentProviderName,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -674,7 +652,7 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Search Provider',
|
ctx.l10n.extensionsSearchProvider,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -683,17 +661,16 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Choose which service to use for searching tracks',
|
ctx.l10n.extensionsSearchProviderDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Default option
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
||||||
title: const Text('Default (Deezer/Spotify)'),
|
title: Text(ctx.l10n.extensionDefaultProvider),
|
||||||
subtitle: const Text('Use built-in search'),
|
subtitle: Text(ctx.l10n.extensionDefaultProviderSubtitle),
|
||||||
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
trailing: (settings.searchProvider == null || settings.searchProvider!.isEmpty)
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
@@ -702,11 +679,10 @@ class _SearchProviderSelector extends ConsumerWidget {
|
|||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Extension options
|
|
||||||
...searchProviders.map((ext) => ListTile(
|
...searchProviders.map((ext) => ListTile(
|
||||||
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
leading: Icon(Icons.extension, color: colorScheme.secondary),
|
||||||
title: Text(ext.displayName),
|
title: Text(ext.displayName),
|
||||||
subtitle: Text(ext.searchBehavior?.placeholder ?? 'Custom search'),
|
subtitle: Text(ext.searchBehavior?.placeholder ?? ctx.l10n.extensionsCustomSearch),
|
||||||
trailing: settings.searchProvider == ext.id
|
trailing: settings.searchProvider == ext.id
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/utils/logger.dart';
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||||
|
|
||||||
@@ -24,14 +25,12 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
LogBuffer().addListener(_onLogUpdate);
|
LogBuffer().addListener(_onLogUpdate);
|
||||||
// Start polling Go backend logs
|
|
||||||
LogBuffer().startGoLogPolling();
|
LogBuffer().startGoLogPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
LogBuffer().removeListener(_onLogUpdate);
|
LogBuffer().removeListener(_onLogUpdate);
|
||||||
// Stop polling when leaving screen
|
|
||||||
LogBuffer().stopGoLogPolling();
|
LogBuffer().stopGoLogPolling();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -67,7 +66,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
Clipboard.setData(ClipboardData(text: logs));
|
Clipboard.setData(ClipboardData(text: logs));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: const Text('Logs copied to clipboard'),
|
content: Text(context.l10n.logCopied),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
@@ -84,19 +83,19 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Clear Logs'),
|
title: Text(context.l10n.logClearLogsTitle),
|
||||||
content: const Text('Are you sure you want to clear all logs?'),
|
content: Text(context.l10n.logClearLogsMessage),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
LogBuffer().clear();
|
LogBuffer().clear();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: const Text('Clear'),
|
child: Text(context.l10n.dialogClear),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -130,7 +129,6 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button - same as other settings pages
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -166,19 +164,19 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'share',
|
value: 'share',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.share),
|
leading: const Icon(Icons.share),
|
||||||
title: Text('Share logs'),
|
title: Text(context.l10n.logShareLogs),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'clear',
|
value: 'clear',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Icon(Icons.delete_outline),
|
leading: const Icon(Icons.delete_outline),
|
||||||
title: Text('Clear logs'),
|
title: Text(context.l10n.logClearLogs),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -195,7 +193,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Logs',
|
context.l10n.logTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio),
|
fontSize: 20 + (8 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -207,14 +205,12 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Filter section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.logFilterSection),
|
||||||
child: SettingsSectionHeader(title: 'Filter'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
// Level filter
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -225,10 +221,10 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
|
Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'Filter logs by severity',
|
context.l10n.logFilterBySeverity,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -268,7 +264,6 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
endIndent: 20,
|
endIndent: 20,
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
// Search field
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -279,7 +274,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search logs...',
|
hintText: context.l10n.logSearchHint,
|
||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
@@ -313,19 +308,18 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Log entries section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsSectionHeader(
|
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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Error summary card - shows detected issues
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: _LogSummaryCard(logs: LogBuffer().entries),
|
child: _LogSummaryCard(logs: LogBuffer().entries),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Log list
|
|
||||||
logs.isEmpty
|
logs.isEmpty
|
||||||
? SliverToBoxAdapter(
|
? SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -342,14 +336,14 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'No logs yet',
|
context.l10n.logNoLogsYet,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Logs will appear here as you use the app',
|
context.l10n.logNoLogsYetSubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
@@ -376,7 +370,6 @@ class _LogScreenState extends State<LogScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom padding
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -415,7 +408,6 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header: time, level, tag
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@@ -475,7 +467,6 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
// Message
|
|
||||||
Text(
|
Text(
|
||||||
entry.message,
|
entry.message,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -485,7 +476,6 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
height: 1.4,
|
height: 1.4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Error if present
|
|
||||||
if (entry.error != null) ...[
|
if (entry.error != null) ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -523,10 +513,8 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Analyze logs for issues
|
|
||||||
final analysis = _analyzeLogs();
|
final analysis = _analyzeLogs();
|
||||||
|
|
||||||
// Don't show if no issues detected
|
|
||||||
if (!analysis.hasIssues) {
|
if (!analysis.hasIssues) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
@@ -544,7 +532,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Header
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -564,7 +551,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// ISP Blocking detected
|
|
||||||
if (analysis.hasISPBlocking) ...[
|
if (analysis.hasISPBlocking) ...[
|
||||||
_IssueBadge(
|
_IssueBadge(
|
||||||
icon: Icons.block,
|
icon: Icons.block,
|
||||||
@@ -577,7 +563,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
if (analysis.hasRateLimit) ...[
|
if (analysis.hasRateLimit) ...[
|
||||||
_IssueBadge(
|
_IssueBadge(
|
||||||
icon: Icons.speed,
|
icon: Icons.speed,
|
||||||
@@ -589,7 +574,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Network errors
|
|
||||||
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
|
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
|
||||||
_IssueBadge(
|
_IssueBadge(
|
||||||
icon: Icons.wifi_off,
|
icon: Icons.wifi_off,
|
||||||
@@ -601,7 +585,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Track not found
|
|
||||||
if (analysis.hasNotFound) ...[
|
if (analysis.hasNotFound) ...[
|
||||||
_IssueBadge(
|
_IssueBadge(
|
||||||
icon: Icons.search_off,
|
icon: Icons.search_off,
|
||||||
@@ -612,7 +595,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Error count
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'Total errors: ${analysis.errorCount}',
|
'Total errors: ${analysis.errorCount}',
|
||||||
@@ -644,7 +626,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
final errorLower = (log.error ?? '').toLowerCase();
|
final errorLower = (log.error ?? '').toLowerCase();
|
||||||
final combined = '$msgLower $errorLower';
|
final combined = '$msgLower $errorLower';
|
||||||
|
|
||||||
// Check for ISP blocking (detected by Go backend)
|
|
||||||
if (combined.contains('isp blocking') ||
|
if (combined.contains('isp blocking') ||
|
||||||
combined.contains('isp may be') ||
|
combined.contains('isp may be') ||
|
||||||
combined.contains('blocked by isp') ||
|
combined.contains('blocked by isp') ||
|
||||||
@@ -652,21 +633,18 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
combined.contains('connection refused')) {
|
combined.contains('connection refused')) {
|
||||||
hasISPBlocking = true;
|
hasISPBlocking = true;
|
||||||
|
|
||||||
// Try to extract domain
|
|
||||||
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
||||||
if (domainMatch != null) {
|
if (domainMatch != null) {
|
||||||
blockedDomains.add(domainMatch.group(1)!);
|
blockedDomains.add(domainMatch.group(1)!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for rate limiting
|
|
||||||
if (combined.contains('rate limit') ||
|
if (combined.contains('rate limit') ||
|
||||||
combined.contains('429') ||
|
combined.contains('429') ||
|
||||||
combined.contains('too many requests')) {
|
combined.contains('too many requests')) {
|
||||||
hasRateLimit = true;
|
hasRateLimit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for network errors
|
|
||||||
if (combined.contains('connection') ||
|
if (combined.contains('connection') ||
|
||||||
combined.contains('timeout') ||
|
combined.contains('timeout') ||
|
||||||
combined.contains('network') ||
|
combined.contains('network') ||
|
||||||
@@ -674,7 +652,6 @@ class _LogSummaryCard extends StatelessWidget {
|
|||||||
hasNetworkError = true;
|
hasNetworkError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for not found
|
|
||||||
if (combined.contains('not found') ||
|
if (combined.contains('not found') ||
|
||||||
combined.contains('no results') ||
|
combined.contains('no results') ||
|
||||||
combined.contains('could not find')) {
|
combined.contains('could not find')) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||||
@@ -23,16 +24,13 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
|
final allProviders = ref.read(extensionProvider.notifier).getAllMetadataProviders();
|
||||||
|
|
||||||
// Use saved priority if available, otherwise use default order
|
|
||||||
if (extState.metadataProviderPriority.isNotEmpty) {
|
if (extState.metadataProviderPriority.isNotEmpty) {
|
||||||
_providers = List.from(extState.metadataProviderPriority);
|
_providers = List.from(extState.metadataProviderPriority);
|
||||||
// Add any new providers not in saved priority
|
|
||||||
for (final provider in allProviders) {
|
for (final provider in allProviders) {
|
||||||
if (!_providers.contains(provider)) {
|
if (!_providers.contains(provider)) {
|
||||||
_providers.add(provider);
|
_providers.add(provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove providers that no longer exist
|
|
||||||
_providers.removeWhere((p) => !allProviders.contains(p));
|
_providers.removeWhere((p) => !allProviders.contains(p));
|
||||||
} else {
|
} else {
|
||||||
_providers = allProviders;
|
_providers = allProviders;
|
||||||
@@ -56,7 +54,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -81,7 +78,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
if (_hasChanges)
|
if (_hasChanges)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _saveChanges,
|
onPressed: _saveChanges,
|
||||||
child: const Text('Save'),
|
child: Text(context.l10n.dialogSave),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
@@ -96,7 +93,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Metadata Priority',
|
context.l10n.metadataProviderPriorityTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio),
|
fontSize: 20 + (8 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -108,13 +105,11 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Description
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Drag to reorder metadata providers. The app will try providers '
|
context.l10n.metadataProviderPriorityDescription,
|
||||||
'from top to bottom when searching for tracks and fetching metadata.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -122,7 +117,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Provider list
|
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
sliver: SliverReorderableList(
|
sliver: SliverReorderableList(
|
||||||
@@ -150,7 +144,6 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Info section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -166,8 +159,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Deezer has no rate limits and is recommended as primary. '
|
context.l10n.metadataProviderPriorityInfo,
|
||||||
'Spotify may rate limit after many requests.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -190,16 +182,16 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Discard Changes?'),
|
title: Text(context.l10n.dialogDiscardChanges),
|
||||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
content: Text(context.l10n.dialogUnsavedChanges),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text('Discard'),
|
child: Text(context.l10n.dialogDiscard),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -214,7 +206,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Metadata provider priority saved')),
|
SnackBar(content: Text(context.l10n.snackbarMetadataProviderSaved)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -246,7 +238,7 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: colorScheme.surfaceContainerHigh;
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
final info = _getProviderInfo(provider);
|
final info = _getProviderInfo(context, provider);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
@@ -259,7 +251,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Priority number
|
|
||||||
Container(
|
Container(
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -282,7 +273,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Provider icon
|
|
||||||
Icon(
|
Icon(
|
||||||
info.icon,
|
info.icon,
|
||||||
color: info.isBuiltIn
|
color: info.isBuiltIn
|
||||||
@@ -290,7 +280,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
: colorScheme.secondary,
|
: colorScheme.secondary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Provider name
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -310,7 +299,6 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Drag handle
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.drag_handle,
|
Icons.drag_handle,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -323,28 +311,27 @@ class _MetadataProviderItem extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_MetadataProviderInfo _getProviderInfo(String provider) {
|
_MetadataProviderInfo _getProviderInfo(BuildContext context, String provider) {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case 'deezer':
|
case 'deezer':
|
||||||
return _MetadataProviderInfo(
|
return _MetadataProviderInfo(
|
||||||
name: 'Deezer',
|
name: 'Deezer',
|
||||||
icon: Icons.album,
|
icon: Icons.album,
|
||||||
description: 'No rate limits',
|
description: context.l10n.metadataNoRateLimits,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
case 'spotify':
|
case 'spotify':
|
||||||
return _MetadataProviderInfo(
|
return _MetadataProviderInfo(
|
||||||
name: 'Spotify',
|
name: 'Spotify',
|
||||||
icon: Icons.music_note,
|
icon: Icons.music_note,
|
||||||
description: 'May rate limit',
|
description: context.l10n.metadataMayRateLimit,
|
||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
// Extension provider
|
|
||||||
return _MetadataProviderInfo(
|
return _MetadataProviderInfo(
|
||||||
name: provider,
|
name: provider,
|
||||||
icon: Icons.extension,
|
icon: Icons.extension,
|
||||||
description: 'Extension',
|
description: context.l10n.providerExtension,
|
||||||
isBuiltIn: false,
|
isBuiltIn: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/models/settings.dart';
|
import 'package:spotiflac_android/models/settings.dart';
|
||||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
@@ -22,7 +23,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar with back button
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -50,7 +50,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
bottom: 16,
|
bottom: 16,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Options',
|
context.l10n.optionsTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
fontSize: 20 + (8 * expandRatio), // 20 -> 28
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -62,9 +62,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Search Source section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionSearchSource),
|
||||||
child: SettingsSectionHeader(title: 'Search Source'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -76,7 +75,6 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
.setMetadataSource(v),
|
.setMetadataSource(v),
|
||||||
),
|
),
|
||||||
if (settings.metadataSource == 'spotify') ...[
|
if (settings.metadataSource == 'spotify') ...[
|
||||||
// Info card about Spotify credentials requirement
|
|
||||||
if (settings.spotifyClientId.isEmpty)
|
if (settings.spotifyClientId.isEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
@@ -93,7 +91,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Spotify requires your own API credentials. Get them free from developer.spotify.com',
|
context.l10n.optionsSpotifyWarning,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -107,10 +105,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.key,
|
icon: Icons.key,
|
||||||
title: 'Spotify Credentials',
|
title: context.l10n.optionsSpotifyCredentials,
|
||||||
subtitle: settings.spotifyClientId.isNotEmpty
|
subtitle: settings.spotifyClientId.isNotEmpty
|
||||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
? context.l10n.optionsSpotifyCredentialsConfigured(settings.spotifyClientId.length > 8 ? settings.spotifyClientId.substring(0, 8) : settings.spotifyClientId)
|
||||||
: 'Required - tap to configure',
|
: context.l10n.optionsSpotifyCredentialsRequired,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_showSpotifyCredentialsDialog(context, ref, settings),
|
_showSpotifyCredentialsDialog(context, ref, settings),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
@@ -129,17 +127,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Download options section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionDownload),
|
||||||
child: SettingsSectionHeader(title: 'Download'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.sync,
|
icon: Icons.sync,
|
||||||
title: 'Auto Fallback',
|
title: context.l10n.optionsAutoFallback,
|
||||||
subtitle: 'Try other services if download fails',
|
subtitle: context.l10n.optionsAutoFallbackSubtitle,
|
||||||
value: settings.autoFallback,
|
value: settings.autoFallback,
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
ref.read(settingsProvider.notifier).setAutoFallback(v),
|
||||||
@@ -147,10 +144,10 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
if (hasExtensions)
|
if (hasExtensions)
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.extension,
|
icon: Icons.extension,
|
||||||
title: 'Use Extension Providers',
|
title: context.l10n.optionsUseExtensionProviders,
|
||||||
subtitle: settings.useExtensionProviders
|
subtitle: settings.useExtensionProviders
|
||||||
? 'Extensions will be tried first'
|
? context.l10n.optionsUseExtensionProvidersOn
|
||||||
: 'Using built-in providers only',
|
: context.l10n.optionsUseExtensionProvidersOff,
|
||||||
value: settings.useExtensionProviders,
|
value: settings.useExtensionProviders,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -158,16 +155,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.lyrics,
|
icon: Icons.lyrics,
|
||||||
title: 'Embed Lyrics',
|
title: context.l10n.optionsEmbedLyrics,
|
||||||
subtitle: 'Embed synced lyrics into FLAC files',
|
subtitle: context.l10n.optionsEmbedLyricsSubtitle,
|
||||||
value: settings.embedLyrics,
|
value: settings.embedLyrics,
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
ref.read(settingsProvider.notifier).setEmbedLyrics(v),
|
||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.image,
|
icon: Icons.image,
|
||||||
title: 'Max Quality Cover',
|
title: context.l10n.optionsMaxQualityCover,
|
||||||
subtitle: 'Download highest resolution cover art',
|
subtitle: context.l10n.optionsMaxQualityCoverSubtitle,
|
||||||
value: settings.maxQualityCover,
|
value: settings.maxQualityCover,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -178,9 +175,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Performance section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionPerformance),
|
||||||
child: SettingsSectionHeader(title: 'Performance'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
@@ -195,17 +191,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// App section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionApp),
|
||||||
child: SettingsSectionHeader(title: 'App'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.store,
|
icon: Icons.store,
|
||||||
title: 'Extension Store',
|
title: context.l10n.optionsExtensionStore,
|
||||||
subtitle: 'Show Store tab in navigation',
|
subtitle: context.l10n.optionsExtensionStoreSubtitle,
|
||||||
value: settings.showExtensionStore,
|
value: settings.showExtensionStore,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -213,8 +208,8 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.system_update,
|
icon: Icons.system_update,
|
||||||
title: 'Check for Updates',
|
title: context.l10n.optionsCheckUpdates,
|
||||||
subtitle: 'Notify when new version is available',
|
subtitle: context.l10n.optionsCheckUpdatesSubtitle,
|
||||||
value: settings.checkForUpdates,
|
value: settings.checkForUpdates,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(settingsProvider.notifier)
|
.read(settingsProvider.notifier)
|
||||||
@@ -229,17 +224,16 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Data section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionData),
|
||||||
child: SettingsSectionHeader(title: 'Data'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsItem(
|
SettingsItem(
|
||||||
icon: Icons.delete_forever,
|
icon: Icons.delete_forever,
|
||||||
title: 'Clear Download History',
|
title: context.l10n.optionsClearHistory,
|
||||||
subtitle: 'Remove all downloaded tracks from history',
|
subtitle: context.l10n.optionsClearHistorySubtitle,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
_showClearHistoryDialog(context, ref, colorScheme),
|
_showClearHistoryDialog(context, ref, colorScheme),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
@@ -248,19 +242,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Debug section
|
SliverToBoxAdapter(
|
||||||
const SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: context.l10n.sectionDebug),
|
||||||
child: SettingsSectionHeader(title: 'Debug'),
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.bug_report,
|
icon: Icons.bug_report,
|
||||||
title: 'Detailed Logging',
|
title: context.l10n.optionsDetailedLogging,
|
||||||
subtitle: settings.enableLogging
|
subtitle: settings.enableLogging
|
||||||
? 'Detailed logs are being recorded'
|
? context.l10n.optionsDetailedLoggingOn
|
||||||
: 'Enable for bug reports',
|
: context.l10n.optionsDetailedLoggingOff,
|
||||||
value: settings.enableLogging,
|
value: settings.enableLogging,
|
||||||
onChanged: (v) =>
|
onChanged: (v) =>
|
||||||
ref.read(settingsProvider.notifier).setEnableLogging(v),
|
ref.read(settingsProvider.notifier).setEnableLogging(v),
|
||||||
@@ -285,14 +278,14 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Clear History'),
|
title: Text(context.l10n.dialogClearHistoryTitle),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'Are you sure you want to clear all download history? This cannot be undone.',
|
context.l10n.dialogClearHistoryMessage,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -300,9 +293,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
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 +346,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'Spotify Credentials',
|
context.l10n.credentialsTitle,
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
@@ -361,7 +354,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Enter your Client ID and Secret to use your own Spotify application quota.',
|
context.l10n.credentialsDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -369,12 +362,11 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// Client ID
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: clientIdController,
|
controller: clientIdController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Client ID',
|
labelText: context.l10n.credentialsClientId,
|
||||||
hintText: 'Paste Client ID',
|
hintText: context.l10n.credentialsClientIdHint,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||||
alpha: 0.3,
|
alpha: 0.3,
|
||||||
@@ -407,13 +399,12 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Client Secret
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: clientSecretController,
|
controller: clientSecretController,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Client Secret',
|
labelText: context.l10n.credentialsClientSecret,
|
||||||
hintText: 'Paste Client Secret',
|
hintText: context.l10n.credentialsClientSecretHint,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||||
alpha: 0.3,
|
alpha: 0.3,
|
||||||
@@ -458,12 +449,12 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
.setSpotifyCredentials(clientId, clientSecret);
|
.setSpotifyCredentials(clientId, clientSecret);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Credentials saved')),
|
SnackBar(content: Text(context.l10n.snackbarCredentialsSaved)),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Please fill all fields'),
|
content: Text(context.l10n.snackbarFillAllFields),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -474,9 +465,9 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Text(
|
||||||
'Save Credentials',
|
context.l10n.actionSaveCredentials,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -489,14 +480,14 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||||||
.clearSpotifyCredentials();
|
.clearSpotifyCredentials();
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Credentials cleared')),
|
SnackBar(content: Text(context.l10n.snackbarCredentialsCleared)),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: colorScheme.error,
|
foregroundColor: colorScheme.error,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
),
|
),
|
||||||
child: const Text('Remove Credentials'),
|
child: Text(context.l10n.actionRemoveCredentials),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -540,14 +531,14 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Concurrent Downloads',
|
context.l10n.optionsConcurrentDownloads,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
currentValue == 1
|
currentValue == 1
|
||||||
? 'Sequential (1 at a time)'
|
? context.l10n.optionsConcurrentSequential
|
||||||
: '$currentValue parallel downloads',
|
: context.l10n.optionsConcurrentParallel(currentValue),
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -590,7 +581,7 @@ class _ConcurrentDownloadsItem extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Parallel downloads may trigger rate limiting',
|
context.l10n.optionsConcurrentWarning,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
|
||||||
@@ -682,14 +673,14 @@ class _UpdateChannelSelector extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Update Channel',
|
context.l10n.optionsUpdateChannel,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
currentChannel == 'preview'
|
currentChannel == 'preview'
|
||||||
? 'Get preview releases'
|
? context.l10n.optionsUpdateChannelPreview
|
||||||
: 'Stable releases only',
|
: context.l10n.optionsUpdateChannelStable,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -703,13 +694,13 @@ class _UpdateChannelSelector extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
_ChannelChip(
|
_ChannelChip(
|
||||||
label: 'Stable',
|
label: context.l10n.channelStable,
|
||||||
isSelected: currentChannel == 'stable',
|
isSelected: currentChannel == 'stable',
|
||||||
onTap: () => onChanged('stable'),
|
onTap: () => onChanged('stable'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_ChannelChip(
|
_ChannelChip(
|
||||||
label: 'Preview',
|
label: context.l10n.channelPreview,
|
||||||
isSelected: currentChannel == 'preview',
|
isSelected: currentChannel == 'preview',
|
||||||
onTap: () => onChanged('preview'),
|
onTap: () => onChanged('preview'),
|
||||||
),
|
),
|
||||||
@@ -726,7 +717,7 @@ class _UpdateChannelSelector extends StatelessWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Preview may contain bugs or incomplete features',
|
context.l10n.optionsUpdateChannelWarning,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -803,7 +794,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
final settings = ref.watch(settingsProvider);
|
final settings = ref.watch(settingsProvider);
|
||||||
final extState = ref.watch(extensionProvider);
|
final extState = ref.watch(extensionProvider);
|
||||||
|
|
||||||
// Check if extension search provider is active AND enabled
|
|
||||||
Extension? activeExtension;
|
Extension? activeExtension;
|
||||||
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) {
|
||||||
activeExtension = extState.extensions
|
activeExtension = extState.extensions
|
||||||
@@ -823,7 +813,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Primary Provider',
|
context.l10n.optionsPrimaryProvider,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||||
@@ -831,8 +821,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
hasExtensionSearch
|
hasExtensionSearch
|
||||||
? 'Using extension: $extensionName'
|
? context.l10n.optionsUsingExtension(extensionName!)
|
||||||
: 'Service used when searching by track name.',
|
: context.l10n.optionsPrimaryProviderSubtitle,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: hasExtensionSearch
|
color: hasExtensionSearch
|
||||||
? colorScheme.primary
|
? colorScheme.primary
|
||||||
@@ -845,10 +835,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
_SourceChip(
|
_SourceChip(
|
||||||
icon: Icons.graphic_eq,
|
icon: Icons.graphic_eq,
|
||||||
label: 'Deezer',
|
label: 'Deezer',
|
||||||
// Not selected if extension is active
|
|
||||||
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// If extension was active, reset it to default
|
|
||||||
if (hasExtensionSearch) {
|
if (hasExtensionSearch) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
}
|
}
|
||||||
@@ -859,10 +847,8 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
_SourceChip(
|
_SourceChip(
|
||||||
icon: Icons.music_note,
|
icon: Icons.music_note,
|
||||||
label: 'Spotify',
|
label: 'Spotify',
|
||||||
// Not selected if extension is active
|
|
||||||
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
// If extension was active, reset it to default
|
|
||||||
if (hasExtensionSearch) {
|
if (hasExtensionSearch) {
|
||||||
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
ref.read(settingsProvider.notifier).setSearchProvider(null);
|
||||||
}
|
}
|
||||||
@@ -883,7 +869,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Tap Deezer or Spotify to switch back from extension',
|
context.l10n.optionsSwitchBack,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -903,16 +889,12 @@ class _SourceChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final String? badge;
|
|
||||||
final Color? badgeColor;
|
|
||||||
|
|
||||||
const _SourceChip({
|
const _SourceChip({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.isSelected,
|
required this.isSelected,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.badge,
|
|
||||||
this.badgeColor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -958,24 +940,6 @@ class _SourceChip extends StatelessWidget {
|
|||||||
: colorScheme.onSurfaceVariant,
|
: 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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||||
@@ -23,17 +24,13 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
final extState = ref.read(extensionProvider);
|
final extState = ref.read(extensionProvider);
|
||||||
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
final allProviders = ref.read(extensionProvider.notifier).getAllDownloadProviders();
|
||||||
|
|
||||||
// Use saved priority if available, otherwise use default order
|
|
||||||
if (extState.providerPriority.isNotEmpty) {
|
if (extState.providerPriority.isNotEmpty) {
|
||||||
// Start with saved priority
|
|
||||||
_providers = List.from(extState.providerPriority);
|
_providers = List.from(extState.providerPriority);
|
||||||
// Add any new providers not in saved priority
|
|
||||||
for (final provider in allProviders) {
|
for (final provider in allProviders) {
|
||||||
if (!_providers.contains(provider)) {
|
if (!_providers.contains(provider)) {
|
||||||
_providers.add(provider);
|
_providers.add(provider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Remove providers that no longer exist
|
|
||||||
_providers.removeWhere((p) => !allProviders.contains(p));
|
_providers.removeWhere((p) => !allProviders.contains(p));
|
||||||
} else {
|
} else {
|
||||||
_providers = allProviders;
|
_providers = allProviders;
|
||||||
@@ -57,7 +54,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: CustomScrollView(
|
body: CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -82,7 +78,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
if (_hasChanges)
|
if (_hasChanges)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: _saveChanges,
|
onPressed: _saveChanges,
|
||||||
child: const Text('Save'),
|
child: Text(context.l10n.dialogSave),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
@@ -97,7 +93,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Provider Priority',
|
context.l10n.providerPriorityTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (8 * expandRatio),
|
fontSize: 20 + (8 * expandRatio),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -109,13 +105,11 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Description
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Drag to reorder download providers. The app will try providers '
|
context.l10n.providerPriorityDescription,
|
||||||
'from top to bottom when downloading tracks.',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -123,7 +117,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Provider list
|
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
sliver: SliverReorderableList(
|
sliver: SliverReorderableList(
|
||||||
@@ -151,7 +144,6 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Info section
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -167,8 +159,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'If a track is not available on the first provider, '
|
context.l10n.providerPriorityInfo,
|
||||||
'the app will automatically try the next one.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onTertiaryContainer,
|
color: colorScheme.onTertiaryContainer,
|
||||||
),
|
),
|
||||||
@@ -191,16 +182,16 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Discard Changes?'),
|
title: Text(context.l10n.dialogDiscardChanges),
|
||||||
content: const Text('You have unsaved changes. Do you want to discard them?'),
|
content: Text(context.l10n.dialogUnsavedChanges),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text('Discard'),
|
child: Text(context.l10n.dialogDiscard),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -215,7 +206,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Provider priority saved')),
|
SnackBar(content: Text(context.l10n.snackbarProviderPrioritySaved)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,7 +238,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
: colorScheme.surfaceContainerHigh;
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
// Get provider info
|
|
||||||
final info = _getProviderInfo(provider);
|
final info = _getProviderInfo(provider);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -261,7 +251,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Priority number
|
|
||||||
Container(
|
Container(
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
@@ -284,7 +273,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Provider icon
|
|
||||||
Icon(
|
Icon(
|
||||||
info.icon,
|
info.icon,
|
||||||
color: info.isBuiltIn
|
color: info.isBuiltIn
|
||||||
@@ -292,7 +280,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
: colorScheme.secondary,
|
: colorScheme.secondary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
// Provider name
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -304,7 +291,7 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
info.isBuiltIn ? 'Built-in' : 'Extension',
|
info.isBuiltIn ? context.l10n.providerBuiltIn : context.l10n.providerExtension,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -312,7 +299,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Drag handle
|
|
||||||
Icon(
|
Icon(
|
||||||
Icons.drag_handle,
|
Icons.drag_handle,
|
||||||
color: colorScheme.onSurfaceVariant,
|
color: colorScheme.onSurfaceVariant,
|
||||||
@@ -346,7 +332,6 @@ class _ProviderItem extends StatelessWidget {
|
|||||||
isBuiltIn: true,
|
isBuiltIn: true,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
// Extension provider
|
|
||||||
return _ProviderInfo(
|
return _ProviderInfo(
|
||||||
name: provider,
|
name: provider,
|
||||||
icon: Icons.extension,
|
icon: Icons.extension,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/constants/app_info.dart';
|
import 'package:spotiflac_android/constants/app_info.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/appearance_settings_page.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/download_settings_page.dart';
|
||||||
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
import 'package:spotiflac_android/screens/settings/extensions_page.dart';
|
||||||
@@ -19,7 +20,6 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar
|
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 120 + topPadding,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
@@ -41,7 +41,7 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Settings',
|
context.l10n.settingsTitle,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
@@ -53,76 +53,82 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// First group: Appearance & Download
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: Builder(
|
||||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
builder: (context) {
|
||||||
children: [
|
final l10n = context.l10n;
|
||||||
SettingsItem(
|
return SettingsGroup(
|
||||||
icon: Icons.palette_outlined,
|
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||||
title: 'Appearance',
|
children: [
|
||||||
subtitle: 'Theme, colors, display',
|
SettingsItem(
|
||||||
onTap: () =>
|
icon: Icons.palette_outlined,
|
||||||
_navigateTo(context, const AppearanceSettingsPage()),
|
title: l10n.settingsAppearance,
|
||||||
),
|
subtitle: l10n.settingsAppearanceSubtitle,
|
||||||
SettingsItem(
|
onTap: () =>
|
||||||
icon: Icons.download_outlined,
|
_navigateTo(context, const AppearanceSettingsPage()),
|
||||||
title: 'Download',
|
),
|
||||||
subtitle: 'Service, quality, filename format',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
icon: Icons.download_outlined,
|
||||||
),
|
title: l10n.settingsDownload,
|
||||||
SettingsItem(
|
subtitle: l10n.settingsDownloadSubtitle,
|
||||||
icon: Icons.tune_outlined,
|
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
|
||||||
title: 'Options',
|
),
|
||||||
subtitle: 'Fallback, lyrics, cover art, updates',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
icon: Icons.tune_outlined,
|
||||||
),
|
title: l10n.settingsOptions,
|
||||||
SettingsItem(
|
subtitle: l10n.settingsOptionsSubtitle,
|
||||||
icon: Icons.extension_outlined,
|
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
|
||||||
title: 'Extensions',
|
),
|
||||||
subtitle: 'Manage download providers',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
icon: Icons.extension_outlined,
|
||||||
showDivider: false,
|
title: l10n.settingsExtensions,
|
||||||
),
|
subtitle: l10n.settingsExtensionsSubtitle,
|
||||||
],
|
onTap: () => _navigateTo(context, const ExtensionsPage()),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Second group: Logs & About
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: Builder(
|
||||||
children: [
|
builder: (context) {
|
||||||
SettingsItem(
|
final l10n = context.l10n;
|
||||||
icon: Icons.article_outlined,
|
return SettingsGroup(
|
||||||
title: 'Logs',
|
children: [
|
||||||
subtitle: 'View app logs for debugging',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const LogScreen()),
|
icon: Icons.article_outlined,
|
||||||
),
|
title: l10n.logTitle,
|
||||||
SettingsItem(
|
subtitle: l10n.settingsLogsSubtitle,
|
||||||
icon: Icons.info_outline,
|
onTap: () => _navigateTo(context, const LogScreen()),
|
||||||
title: 'About',
|
),
|
||||||
subtitle: 'Version ${AppInfo.version}, credits, GitHub',
|
SettingsItem(
|
||||||
onTap: () => _navigateTo(context, const AboutPage()),
|
icon: Icons.info_outline,
|
||||||
showDivider: false,
|
title: l10n.settingsAbout,
|
||||||
),
|
subtitle: '${l10n.aboutVersion} ${AppInfo.version}',
|
||||||
],
|
onTap: () => _navigateTo(context, const AboutPage()),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Fill remaining space
|
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateTo(BuildContext context, Widget page) {
|
void _navigateTo(BuildContext context, Widget page) {
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
// Use PageRouteBuilder for better predictive back gesture support
|
|
||||||
// MaterialPageRoute can cause freeze on some devices with gesture navigation
|
|
||||||
PageRouteBuilder(
|
PageRouteBuilder(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
// Use slide transition similar to MaterialPageRoute
|
|
||||||
const begin = Offset(1.0, 0.0);
|
const begin = Offset(1.0, 0.0);
|
||||||
const end = Offset.zero;
|
const end = Offset.zero;
|
||||||
const curve = Curves.easeInOut;
|
const curve = Curves.easeInOut;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||||
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||||
|
|
||||||
class SetupScreen extends ConsumerStatefulWidget {
|
class SetupScreen extends ConsumerStatefulWidget {
|
||||||
const SetupScreen({super.key});
|
const SetupScreen({super.key});
|
||||||
@@ -24,13 +25,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
int _androidSdkVersion = 0;
|
int _androidSdkVersion = 0;
|
||||||
|
|
||||||
// Spotify API credentials
|
|
||||||
final _clientIdController = TextEditingController();
|
final _clientIdController = TextEditingController();
|
||||||
final _clientSecretController = TextEditingController();
|
final _clientSecretController = TextEditingController();
|
||||||
bool _useSpotifyApi = false;
|
bool _useSpotifyApi = false;
|
||||||
bool _showClientSecret = false;
|
bool _showClientSecret = false;
|
||||||
|
|
||||||
// Total steps: Storage -> Notification (Android 13+) -> Folder -> Spotify API
|
|
||||||
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
int get _totalSteps => _androidSdkVersion >= 33 ? 4 : 3;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -65,22 +64,18 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (Platform.isAndroid) {
|
} else if (Platform.isAndroid) {
|
||||||
// Check storage permission
|
|
||||||
bool storageGranted = false;
|
bool storageGranted = false;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
|
||||||
final manageStatus = await Permission.manageExternalStorage.status;
|
final manageStatus = await Permission.manageExternalStorage.status;
|
||||||
final audioStatus = await Permission.audio.status;
|
final audioStatus = await Permission.audio.status;
|
||||||
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
|
debugPrint('[Permission] Android 13+ check: MANAGE_EXTERNAL_STORAGE=$manageStatus, READ_MEDIA_AUDIO=$audioStatus');
|
||||||
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
|
storageGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
|
||||||
final manageStatus = await Permission.manageExternalStorage.status;
|
final manageStatus = await Permission.manageExternalStorage.status;
|
||||||
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
debugPrint('[Permission] Android 11-12 check: MANAGE_EXTERNAL_STORAGE=$manageStatus');
|
||||||
storageGranted = manageStatus.isGranted;
|
storageGranted = manageStatus.isGranted;
|
||||||
} else {
|
} else {
|
||||||
// Android 10 and below: Use legacy storage permission
|
|
||||||
final storageStatus = await Permission.storage.status;
|
final storageStatus = await Permission.storage.status;
|
||||||
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
|
debugPrint('[Permission] Android 10- check: STORAGE=$storageStatus');
|
||||||
storageGranted = storageStatus.isGranted;
|
storageGranted = storageStatus.isGranted;
|
||||||
@@ -88,7 +83,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
debugPrint('[Permission] Final storageGranted=$storageGranted');
|
debugPrint('[Permission] Final storageGranted=$storageGranted');
|
||||||
|
|
||||||
// Check notification permission (Android 13+)
|
|
||||||
PermissionStatus notificationStatus = PermissionStatus.granted;
|
PermissionStatus notificationStatus = PermissionStatus.granted;
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
notificationStatus = await Permission.notification.status;
|
notificationStatus = await Permission.notification.status;
|
||||||
@@ -114,28 +108,25 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
bool allGranted = false;
|
bool allGranted = false;
|
||||||
|
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// Android 13+: Need BOTH MANAGE_EXTERNAL_STORAGE AND READ_MEDIA_AUDIO
|
|
||||||
|
|
||||||
// First check/request MANAGE_EXTERNAL_STORAGE
|
|
||||||
var manageStatus = await Permission.manageExternalStorage.status;
|
var manageStatus = await Permission.manageExternalStorage.status;
|
||||||
if (!manageStatus.isGranted) {
|
if (!manageStatus.isGranted) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final shouldOpen = await showDialog<bool>(
|
final shouldOpen = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Storage Access Required'),
|
title: Text(context.l10n.setupStorageAccessRequired),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'SpotiFLAC needs "All files access" permission to save music files to your chosen folder.\n\n'
|
'${context.l10n.setupStorageAccessMessage}\n\n'
|
||||||
'Please enable "Allow access to manage all files" in the next screen.',
|
'${context.l10n.setupAllowAccessToManageFiles}',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text('Open Settings'),
|
child: Text(context.l10n.setupOpenSettings),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -143,14 +134,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
if (shouldOpen == true) {
|
if (shouldOpen == true) {
|
||||||
await Permission.manageExternalStorage.request();
|
await Permission.manageExternalStorage.request();
|
||||||
// Re-check after returning from settings
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
manageStatus = await Permission.manageExternalStorage.status;
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then request READ_MEDIA_AUDIO (this shows a dialog)
|
|
||||||
var audioStatus = await Permission.audio.status;
|
var audioStatus = await Permission.audio.status;
|
||||||
if (!audioStatus.isGranted && manageStatus.isGranted) {
|
if (!audioStatus.isGranted && manageStatus.isGranted) {
|
||||||
audioStatus = await Permission.audio.request();
|
audioStatus = await Permission.audio.request();
|
||||||
@@ -159,26 +148,25 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
allGranted = manageStatus.isGranted && audioStatus.isGranted;
|
allGranted = manageStatus.isGranted && audioStatus.isGranted;
|
||||||
|
|
||||||
} else if (_androidSdkVersion >= 30) {
|
} else if (_androidSdkVersion >= 30) {
|
||||||
// Android 11-12: Need MANAGE_EXTERNAL_STORAGE only
|
|
||||||
var manageStatus = await Permission.manageExternalStorage.status;
|
var manageStatus = await Permission.manageExternalStorage.status;
|
||||||
if (!manageStatus.isGranted) {
|
if (!manageStatus.isGranted) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final shouldOpen = await showDialog<bool>(
|
final shouldOpen = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Storage Access Required'),
|
title: Text(context.l10n.setupStorageAccessRequired),
|
||||||
content: const Text(
|
content: Text(
|
||||||
'Android 11+ requires "All files access" permission to save music files.\n\n'
|
'${context.l10n.setupStorageAccessMessageAndroid11}\n\n'
|
||||||
'Please enable "Allow access to manage all files" in the next screen.',
|
'${context.l10n.setupAllowAccessToManageFiles}',
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: const Text('Open Settings'),
|
child: Text(context.l10n.setupOpenSettings),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -186,7 +174,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
if (shouldOpen == true) {
|
if (shouldOpen == true) {
|
||||||
await Permission.manageExternalStorage.request();
|
await Permission.manageExternalStorage.request();
|
||||||
// Re-check after returning from settings
|
|
||||||
await Future.delayed(const Duration(milliseconds: 500));
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
manageStatus = await Permission.manageExternalStorage.status;
|
manageStatus = await Permission.manageExternalStorage.status;
|
||||||
}
|
}
|
||||||
@@ -195,7 +182,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
allGranted = manageStatus.isGranted;
|
allGranted = manageStatus.isGranted;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Android 10 and below: Use legacy storage permission
|
|
||||||
final status = await Permission.storage.request();
|
final status = await Permission.storage.request();
|
||||||
allGranted = status.isGranted;
|
allGranted = status.isGranted;
|
||||||
|
|
||||||
@@ -211,7 +197,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Permission denied. Please grant all permissions to continue.')),
|
SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,7 +224,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_showPermissionDeniedDialog('Notification');
|
_showPermissionDeniedDialog('Notification');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Notification permission not needed for older Android
|
|
||||||
setState(() => _notificationPermissionGranted = true);
|
setState(() => _notificationPermissionGranted = true);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -256,22 +241,21 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text('$permissionType Permission Required'),
|
title: Text(context.l10n.setupPermissionRequired(permissionType)),
|
||||||
content: Text(
|
content: Text(
|
||||||
'$permissionType permission is required for the best experience. '
|
context.l10n.setupPermissionRequiredMessage(permissionType),
|
||||||
'Please grant permission in app settings.',
|
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
openAppSettings();
|
openAppSettings();
|
||||||
},
|
},
|
||||||
child: const Text('Open Settings'),
|
child: Text(context.l10n.setupOpenSettings),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -283,12 +267,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
// iOS: Show options dialog
|
|
||||||
await _showIOSDirectoryOptions();
|
await _showIOSDirectoryOptions();
|
||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
|
||||||
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
|
||||||
dialogTitle: 'Select Download Folder',
|
dialogTitle: context.l10n.setupSelectDownloadFolder,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedDirectory != null) {
|
if (selectedDirectory != null) {
|
||||||
@@ -299,11 +281,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
final useDefault = await showDialog<bool>(
|
final useDefault = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Use Default Folder?'),
|
title: Text(context.l10n.setupUseDefaultFolder),
|
||||||
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
|
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
|
TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.dialogCancel)),
|
||||||
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
|
TextButton(onPressed: () => Navigator.pop(context, true), child: Text(context.l10n.setupUseDefault)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -333,19 +315,19 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
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(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
|
||||||
child: Text(
|
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),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
leading: Icon(Icons.folder_special, color: colorScheme.primary),
|
||||||
title: const Text('App Documents Folder'),
|
title: Text(context.l10n.setupAppDocumentsFolder),
|
||||||
subtitle: const Text('Recommended - accessible via Files app'),
|
subtitle: Text(context.l10n.setupAppDocumentsFolderSubtitle),
|
||||||
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final dir = await _getDefaultDirectory();
|
final dir = await _getDefaultDirectory();
|
||||||
@@ -355,11 +337,10 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
|
||||||
title: const Text('Choose from Files'),
|
title: Text(context.l10n.setupChooseFromFiles),
|
||||||
subtitle: const Text('Select iCloud or other location'),
|
subtitle: Text(context.l10n.setupChooseFromFilesSubtitle),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
// Note: iOS requires folder to have at least one file to be selectable
|
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
setState(() => _selectedDirectory = result);
|
setState(() => _selectedDirectory = result);
|
||||||
@@ -380,7 +361,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -436,7 +417,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
ref.read(settingsProvider.notifier).setDownloadDirectory(_selectedDirectory!);
|
||||||
|
|
||||||
// Save Spotify credentials if provided
|
|
||||||
if (_useSpotifyApi &&
|
if (_useSpotifyApi &&
|
||||||
_clientIdController.text.trim().isNotEmpty &&
|
_clientIdController.text.trim().isNotEmpty &&
|
||||||
_clientSecretController.text.trim().isNotEmpty) {
|
_clientSecretController.text.trim().isNotEmpty) {
|
||||||
@@ -444,10 +424,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
_clientIdController.text.trim(),
|
_clientIdController.text.trim(),
|
||||||
_clientSecretController.text.trim(),
|
_clientSecretController.text.trim(),
|
||||||
);
|
);
|
||||||
// Set search source to Spotify when credentials are provided
|
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
ref.read(settingsProvider.notifier).setMetadataSource('spotify');
|
||||||
} else {
|
} else {
|
||||||
// Use Deezer as default search source (free, no credentials required)
|
|
||||||
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
ref.read(settingsProvider.notifier).setMetadataSource('deezer');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,26 +460,24 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
// Top section - Logo/Title
|
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
|
child: Image.asset('assets/images/logo.png', width: 96, height: 96),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text('SpotiFLAC',
|
Text(context.l10n.appName,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
fontWeight: FontWeight.bold, color: colorScheme.primary)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text('Download Spotify tracks in FLAC',
|
Text(context.l10n.setupDownloadInFlac,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
color: colorScheme.onSurfaceVariant)),
|
color: colorScheme.onSurfaceVariant)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Middle section - Steps and Content
|
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -511,7 +487,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Bottom section - Navigation Buttons
|
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
@@ -529,8 +504,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
Widget _buildStepIndicator(ColorScheme colorScheme) {
|
||||||
final steps = _androidSdkVersion >= 33
|
final steps = _androidSdkVersion >= 33
|
||||||
? ['Storage', 'Notification', 'Folder', 'Spotify']
|
? [context.l10n.setupStepStorage, context.l10n.setupStepNotification, context.l10n.setupStepFolder, context.l10n.setupStepSpotify]
|
||||||
: ['Permission', 'Folder', 'Spotify'];
|
: [context.l10n.setupStepPermission, context.l10n.setupStepFolder, context.l10n.setupStepSpotify];
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -596,15 +571,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
|
|
||||||
bool _isStepCompleted(int step) {
|
bool _isStepCompleted(int step) {
|
||||||
if (_androidSdkVersion >= 33) {
|
if (_androidSdkVersion >= 33) {
|
||||||
// 4 steps: Storage, Notification, Folder, Spotify
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _notificationPermissionGranted;
|
case 1: return _notificationPermissionGranted;
|
||||||
case 2: return _selectedDirectory != null;
|
case 2: return _selectedDirectory != null;
|
||||||
case 3: return false; // Spotify step never shows checkmark (optional)
|
case 3: return false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 3 steps: Permission, Folder, Spotify
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0: return _storagePermissionGranted;
|
case 0: return _storagePermissionGranted;
|
||||||
case 1: return _selectedDirectory != null;
|
case 1: return _selectedDirectory != null;
|
||||||
@@ -637,7 +610,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Icon with container background (M3 style)
|
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -653,7 +625,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
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),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -662,8 +634,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
_storagePermissionGranted
|
_storagePermissionGranted
|
||||||
? 'You can now proceed to the next step.'
|
? context.l10n.setupProceedToNextStep
|
||||||
: 'SpotiFLAC needs storage access to save downloaded music files to your device.',
|
: context.l10n.setupStorageDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -676,7 +648,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.security_rounded),
|
: const Icon(Icons.security_rounded),
|
||||||
label: const Text('Grant Permission'),
|
label: Text(context.l10n.setupGrantPermission),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
@@ -691,7 +663,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Icon with container background (M3 style)
|
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -707,7 +678,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
_notificationPermissionGranted ? 'Notification Permission Granted!' : 'Enable Notifications',
|
_notificationPermissionGranted ? context.l10n.setupNotificationGranted : context.l10n.setupNotificationEnable,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -716,8 +687,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
child: Text(
|
||||||
_notificationPermissionGranted
|
_notificationPermissionGranted
|
||||||
? 'You will receive download progress notifications.'
|
? context.l10n.setupNotificationProgressDescription
|
||||||
: 'Get notified about download progress and completion. This helps you track downloads when the app is in background.',
|
: context.l10n.setupNotificationBackgroundDescription,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -730,7 +701,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: const Icon(Icons.notifications_active_rounded),
|
: const Icon(Icons.notifications_active_rounded),
|
||||||
label: const Text('Enable Notifications'),
|
label: Text(context.l10n.setupEnableNotifications),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
@@ -742,7 +713,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
child: const Text('Skip for now'),
|
child: Text(context.l10n.setupSkipForNow),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -754,7 +725,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Icon with container background (M3 style)
|
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -770,7 +740,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
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),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -802,7 +772,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
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),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -814,7 +784,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
? SizedBox(width: 20, height: 20,
|
? SizedBox(width: 20, height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
child: CircularProgressIndicator(strokeWidth: 2, color: colorScheme.onPrimary))
|
||||||
: Icon(_selectedDirectory != null ? Icons.edit_rounded : Icons.folder_open_rounded),
|
: 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(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
@@ -829,7 +799,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Icon with container background (M3 style)
|
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
@@ -845,7 +814,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
'Spotify API (Optional)',
|
context.l10n.setupSpotifyApiOptional,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -853,14 +822,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Text(
|
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),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Toggle card (M3 style)
|
|
||||||
Card(
|
Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: colorScheme.surfaceContainerHigh,
|
color: colorScheme.surfaceContainerHigh,
|
||||||
@@ -868,9 +836,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: SwitchListTile(
|
child: SwitchListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
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(
|
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),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
secondary: Container(
|
secondary: Container(
|
||||||
@@ -891,7 +859,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Credentials form (animated)
|
|
||||||
AnimatedSize(
|
AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
@@ -906,13 +873,12 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Client ID
|
Text(context.l10n.credentialsClientId, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
Text('Client ID', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _clientIdController,
|
controller: _clientIdController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Enter Spotify Client ID',
|
hintText: context.l10n.setupEnterClientId,
|
||||||
prefixIcon: const Icon(Icons.key_rounded),
|
prefixIcon: const Icon(Icons.key_rounded),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@@ -925,14 +891,13 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Client Secret
|
Text(context.l10n.credentialsClientSecret, style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||||
Text('Client Secret', style: Theme.of(context).textTheme.labelMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _clientSecretController,
|
controller: _clientSecretController,
|
||||||
obscureText: !_showClientSecret,
|
obscureText: !_showClientSecret,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Enter Spotify Client Secret',
|
hintText: context.l10n.setupEnterClientSecret,
|
||||||
prefixIcon: const Icon(Icons.lock_rounded),
|
prefixIcon: const Icon(Icons.lock_rounded),
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
icon: Icon(_showClientSecret ? Icons.visibility_off_rounded : Icons.visibility_rounded),
|
||||||
@@ -949,7 +914,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Info banner
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -962,7 +926,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Get credentials from developer.spotify.com',
|
context.l10n.setupGetCredentialsFromSpotify,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -983,19 +947,17 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
final isLastStep = _currentStep == _totalSteps - 1;
|
final isLastStep = _currentStep == _totalSteps - 1;
|
||||||
final canProceed = _isStepCompleted(_currentStep);
|
final canProceed = _isStepCompleted(_currentStep);
|
||||||
|
|
||||||
// For Spotify step, check if credentials are valid when enabled
|
|
||||||
final isSpotifyStepValid = !_useSpotifyApi ||
|
final isSpotifyStepValid = !_useSpotifyApi ||
|
||||||
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
|
(_clientIdController.text.trim().isNotEmpty && _clientSecretController.text.trim().isNotEmpty);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
// Back button
|
|
||||||
if (_currentStep > 0)
|
if (_currentStep > 0)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => setState(() => _currentStep--),
|
onPressed: () => setState(() => _currentStep--),
|
||||||
icon: const Icon(Icons.arrow_back_rounded),
|
icon: const Icon(Icons.arrow_back_rounded),
|
||||||
label: const Text('Back'),
|
label: Text(context.l10n.setupBack),
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
),
|
),
|
||||||
@@ -1003,7 +965,6 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
else
|
else
|
||||||
const SizedBox(width: 100),
|
const SizedBox(width: 100),
|
||||||
|
|
||||||
// Next/Finish button
|
|
||||||
if (!isLastStep)
|
if (!isLastStep)
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
onPressed: canProceed ? () => setState(() => _currentStep++) : null,
|
||||||
@@ -1011,9 +972,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
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
|
else
|
||||||
@@ -1029,7 +990,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||||||
: Row(
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(_useSpotifyApi ? 'Get Started' : 'Skip & Start'),
|
Text(_useSpotifyApi ? context.l10n.setupGetStarted : context.l10n.setupSkipAndStart),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
const Icon(Icons.check_rounded, size: 18),
|
const Icon(Icons.check_rounded, size: 18),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:path_provider/path_provider.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/store_provider.dart';
|
||||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||||
|
|
||||||
@@ -19,11 +20,8 @@ class _ExtensionDetailsScreenState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Watch store provider to get latest state of this extension (e.g. if updated/installed)
|
|
||||||
final storeState = ref.watch(storeProvider);
|
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 =
|
final liveExtension =
|
||||||
storeState.extensions
|
storeState.extensions
|
||||||
.where((e) => e.id == widget.extension.id)
|
.where((e) => e.id == widget.extension.id)
|
||||||
@@ -40,7 +38,7 @@ class _ExtensionDetailsScreenState
|
|||||||
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
|
_buildInfoCard(context, liveExtension, colorScheme, isDownloading),
|
||||||
_buildSectionHeader(
|
_buildSectionHeader(
|
||||||
context,
|
context,
|
||||||
'About',
|
context.l10n.aboutTitle,
|
||||||
Icons.info_outline,
|
Icons.info_outline,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
),
|
),
|
||||||
@@ -61,7 +59,7 @@ class _ExtensionDetailsScreenState
|
|||||||
|
|
||||||
_buildSectionHeader(
|
_buildSectionHeader(
|
||||||
context,
|
context,
|
||||||
'Capabilities',
|
context.l10n.extensionCapabilities,
|
||||||
Icons.extension_outlined,
|
Icons.extension_outlined,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
),
|
),
|
||||||
@@ -173,9 +171,9 @@ class _ExtensionDetailsScreenState
|
|||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'by ${ext.author}',
|
context.l10n.extensionsAuthor(ext.author),
|
||||||
style: Theme.of(context).textTheme.bodyLarge
|
style: Theme.of(context).textTheme.bodyLarge
|
||||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
@@ -187,7 +185,6 @@ class _ExtensionDetailsScreenState
|
|||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Badges row
|
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
@@ -204,7 +201,7 @@ class _ExtensionDetailsScreenState
|
|||||||
),
|
),
|
||||||
if (ext.isInstalled)
|
if (ext.isInstalled)
|
||||||
_Badge(
|
_Badge(
|
||||||
label: 'Installed',
|
label: context.l10n.storeInstalled,
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primaryContainer,
|
||||||
textColor: colorScheme.onPrimaryContainer,
|
textColor: colorScheme.onPrimaryContainer,
|
||||||
icon: Icons.check,
|
icon: Icons.check,
|
||||||
@@ -214,7 +211,6 @@ class _ExtensionDetailsScreenState
|
|||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Action Buttons
|
|
||||||
if (isDownloading)
|
if (isDownloading)
|
||||||
Center(
|
Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
@@ -226,7 +222,7 @@ class _ExtensionDetailsScreenState
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _updateExtension(ext),
|
onPressed: () => _updateExtension(ext),
|
||||||
icon: const Icon(Icons.update),
|
icon: const Icon(Icons.update),
|
||||||
label: Text('Update to v${ext.version}'),
|
label: Text('${context.l10n.storeUpdate} v${ext.version}'),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(52),
|
minimumSize: const Size.fromHeight(52),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -241,7 +237,7 @@ class _ExtensionDetailsScreenState
|
|||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
onPressed: null,
|
onPressed: null,
|
||||||
icon: const Icon(Icons.check),
|
icon: const Icon(Icons.check),
|
||||||
label: const Text('Installed'),
|
label: Text(context.l10n.storeInstalled),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
minimumSize: const Size(0, 52),
|
minimumSize: const Size(0, 52),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -262,7 +258,7 @@ class _ExtensionDetailsScreenState
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
tooltip: 'Uninstall',
|
tooltip: context.l10n.extensionsUninstall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@@ -270,7 +266,7 @@ class _ExtensionDetailsScreenState
|
|||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => _installExtension(ext),
|
onPressed: () => _installExtension(ext),
|
||||||
icon: const Icon(Icons.download),
|
icon: const Icon(Icons.download),
|
||||||
label: const Text('Install Extension'),
|
label: Text(context.l10n.storeInstall),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
minimumSize: const Size.fromHeight(52),
|
minimumSize: const Size.fromHeight(52),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@@ -380,19 +376,19 @@ class _ExtensionDetailsScreenState
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'Updated',
|
label: context.l10n.extensionUpdated,
|
||||||
value: ext.updatedAt.isNotEmpty
|
value: ext.updatedAt.isNotEmpty
|
||||||
? _formatDate(ext.updatedAt)
|
? _formatDate(context, ext.updatedAt)
|
||||||
: '-',
|
: '-',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'ID',
|
label: context.l10n.extensionId,
|
||||||
value: ext.id,
|
value: ext.id,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
_MetadataRow(
|
_MetadataRow(
|
||||||
label: 'Min App Version',
|
label: context.l10n.extensionMinAppVersion,
|
||||||
value: ext.minAppVersion ?? 'Any',
|
value: ext.minAppVersion ?? 'Any',
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
isLast: true,
|
isLast: true,
|
||||||
@@ -409,7 +405,6 @@ class _ExtensionDetailsScreenState
|
|||||||
StoreExtension ext,
|
StoreExtension ext,
|
||||||
ColorScheme colorScheme,
|
ColorScheme colorScheme,
|
||||||
) {
|
) {
|
||||||
// Determine capabilities based on category
|
|
||||||
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
|
final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration';
|
||||||
final isDownloadProvider = ext.category == 'download';
|
final isDownloadProvider = ext.category == 'download';
|
||||||
final isLyricsProvider = ext.category == 'lyrics';
|
final isLyricsProvider = ext.category == 'lyrics';
|
||||||
@@ -428,19 +423,19 @@ class _ExtensionDetailsScreenState
|
|||||||
children: [
|
children: [
|
||||||
_CapabilityRow(
|
_CapabilityRow(
|
||||||
icon: Icons.search,
|
icon: Icons.search,
|
||||||
label: 'Metadata Provider',
|
label: context.l10n.extensionMetadataProvider,
|
||||||
enabled: isMetadataProvider,
|
enabled: isMetadataProvider,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
_CapabilityRow(
|
_CapabilityRow(
|
||||||
icon: Icons.download,
|
icon: Icons.download,
|
||||||
label: 'Download Provider',
|
label: context.l10n.extensionDownloadProvider,
|
||||||
enabled: isDownloadProvider,
|
enabled: isDownloadProvider,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
_CapabilityRow(
|
_CapabilityRow(
|
||||||
icon: Icons.lyrics,
|
icon: Icons.lyrics,
|
||||||
label: 'Lyrics Provider',
|
label: context.l10n.extensionLyricsProvider,
|
||||||
enabled: isLyricsProvider,
|
enabled: isLyricsProvider,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
),
|
),
|
||||||
@@ -458,22 +453,22 @@ class _ExtensionDetailsScreenState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatDate(String dateStr) {
|
String _formatDate(BuildContext context, String dateStr) {
|
||||||
try {
|
try {
|
||||||
final date = DateTime.parse(dateStr);
|
final date = DateTime.parse(dateStr);
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final diff = now.difference(date);
|
final diff = now.difference(date);
|
||||||
|
|
||||||
if (diff.inDays == 0) {
|
if (diff.inDays == 0) {
|
||||||
return 'Today';
|
return context.l10n.dateToday;
|
||||||
} else if (diff.inDays == 1) {
|
} else if (diff.inDays == 1) {
|
||||||
return 'Yesterday';
|
return context.l10n.dateYesterday;
|
||||||
} else if (diff.inDays < 7) {
|
} else if (diff.inDays < 7) {
|
||||||
return '${diff.inDays} days ago';
|
return context.l10n.dateDaysAgo(diff.inDays);
|
||||||
} else if (diff.inDays < 30) {
|
} else if (diff.inDays < 30) {
|
||||||
return '${(diff.inDays / 7).floor()} weeks ago';
|
return context.l10n.dateWeeksAgo((diff.inDays / 7).floor());
|
||||||
} else if (diff.inDays < 365) {
|
} else if (diff.inDays < 365) {
|
||||||
return '${(diff.inDays / 30).floor()} months ago';
|
return context.l10n.dateMonthsAgo((diff.inDays / 30).floor());
|
||||||
} else {
|
} else {
|
||||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
@@ -530,8 +525,8 @@ class _ExtensionDetailsScreenState
|
|||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
success
|
success
|
||||||
? '${ext.displayName} installed.'
|
? context.l10n.snackbarExtensionInstalled(ext.displayName)
|
||||||
: 'Failed to install ${ext.displayName}',
|
: context.l10n.snackbarFailedToInstall,
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -551,8 +546,8 @@ class _ExtensionDetailsScreenState
|
|||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
success
|
success
|
||||||
? '${ext.displayName} updated.'
|
? context.l10n.snackbarExtensionUpdated(ext.displayName)
|
||||||
: 'Failed to update ${ext.displayName}',
|
: context.l10n.snackbarFailedToUpdate,
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -564,17 +559,17 @@ class _ExtensionDetailsScreenState
|
|||||||
final confirm = await showDialog<bool>(
|
final confirm = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: const Text('Uninstall Extension?'),
|
title: Text(context.l10n.dialogUninstallExtension),
|
||||||
content: Text('Are you sure you want to remove ${ext.displayName}?'),
|
content: Text(context.l10n.dialogUninstallExtensionMessage(ext.displayName)),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, false),
|
onPressed: () => Navigator.pop(context, false),
|
||||||
child: const Text('Cancel'),
|
child: Text(context.l10n.dialogCancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context, true),
|
onPressed: () => Navigator.pop(context, true),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Uninstall',
|
context.l10n.dialogUninstall,
|
||||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||