mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 03:37:56 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24ef66be4c | |||
| d07a49f605 | |||
| 4eba28db7a | |||
| b73a3f8912 | |||
| 9f47f2ce85 | |||
| f2aca734a3 | |||
| 09cb637a86 |
+110
@@ -1,5 +1,115 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.2.7] - 2026-01-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **CSV Import Metadata Enrichment**: Tracks imported from CSV now automatically fetch metadata from Deezer
|
||||||
|
- Cover art, duration, track/disc number fetched via ISRC lookup
|
||||||
|
- Fallback to text search (artist + track name) when ISRC not found in Deezer
|
||||||
|
- Progress dialog shows enrichment status during import
|
||||||
|
- Ensures downloaded files have proper cover art and metadata
|
||||||
|
- **Deezer Metadata Support**: Enhanced metadata viewer for Deezer tracks
|
||||||
|
- "Open in Deezer" button for Deezer-sourced tracks (opens app or web)
|
||||||
|
- Displays "Deezer ID" instead of "Spotify ID" when applicable
|
||||||
|
- **Smart Tag Injection**: Filename format editor intelligently handles separators
|
||||||
|
- Auto-detects if " - " is needed between tags
|
||||||
|
- Prevents double separators or missing spaces
|
||||||
|
- **Dynamic Source Info**: Search source selector now shows helpful context
|
||||||
|
- "No login required" for Deezer
|
||||||
|
- "Requires credentials" for Spotify
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **UI Modernization**: Major UI consistency updates across the app
|
||||||
|
- **Unified App Bars**: Home, History, and Settings now share identical behavior
|
||||||
|
- Lowered expanded header for easier one-handed reachability
|
||||||
|
- Dynamic title text scaling (20px to 34px)
|
||||||
|
- **Appearance Settings**: Completely redesigned appearance page
|
||||||
|
- New "Theme Preview" card showing visualizing current theme
|
||||||
|
- Modern color palette picker replacing old color dots
|
||||||
|
- Clean, grouped layout
|
||||||
|
- "AMOLED Dark" switch is now hidden when using Light Mode
|
||||||
|
- **App Logo**: Refined logo style on Home and About screens
|
||||||
|
- Inverted colors: Filled primary color circle with on-color icon
|
||||||
|
- Removed padding for a cleaner, bolder look
|
||||||
|
- **Material 3 Switches**: Added checkmark icon to active switches
|
||||||
|
- **UI Modernization (Global)**: Complete design refresh for a cleaner, modern look
|
||||||
|
- **Rounded Corners**: Standardized 16px radius for all cards, buttons, and input fields
|
||||||
|
- **Transparent Elements**: Applied subtle transparency to input fields and containers using `surfaceContainerHighest`
|
||||||
|
- **Consistent Buttons**: Unified button styling across the app (pill shape, 16px radius)
|
||||||
|
- **Options Settings Redesign**: improved layout and usability
|
||||||
|
- **Search Source Priority**: Moved "Search Source" section to the very top for quick access
|
||||||
|
- **Compact Source Selector**: Redesigned provider toggle (Deezer/Spotify) to be compact and consistent
|
||||||
|
- **Credentials Workflow**: Reorganized Custom Credentials settings; toggle now auto-prompts if credentials missing
|
||||||
|
- **Modern Credentials Dialog**: Totally redesigned input dialog for Spotify Client ID/Secret
|
||||||
|
- **Filename Format Editor 2.0**:
|
||||||
|
- **Modern Sheet UI**: Replaced legacy dialog with a clean, full-width bottom sheet
|
||||||
|
- **Tag Chips**: Added clickable chips ({artist}, {title}) for one-tap insertion
|
||||||
|
- **Smart Formatting**: Automatically injects separators (" - ") when adding tags for faster editing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **CSV Import Missing Cover Art**: Fixed tracks from CSV having no cover art in download history
|
||||||
|
- Cover URL now properly fetched from Deezer during enrichment
|
||||||
|
- Falls back to text search when ISRC lookup fails
|
||||||
|
- **CSV Import Missing Duration**: Fixed duration showing 0:00 for CSV-imported tracks
|
||||||
|
- Duration now fetched from Deezer metadata during enrichment
|
||||||
|
- **Disc Number Not Displayed**: Fixed disc number not showing in track metadata screen
|
||||||
|
- Changed condition from `discNumber > 0` to `discNumber > 0`
|
||||||
|
- Now displays disc 1 instead of hiding it
|
||||||
|
- **Download History Using Wrong Track Data**: Fixed history using original CSV data instead of enriched data
|
||||||
|
- Now uses `trackToDownload` (enriched) instead of `item.track` (original)
|
||||||
|
|
||||||
|
### Technical
|
||||||
|
|
||||||
|
- Updated `lib/services/csv_import_service.dart`:
|
||||||
|
- Added `_enrichTracksMetadata()` with ISRC lookup + text search fallback
|
||||||
|
- Added progress callback for UI feedback
|
||||||
|
- Updated `lib/screens/home_tab.dart`:
|
||||||
|
- Added progress dialog during CSV enrichment
|
||||||
|
- Updated `lib/providers/download_queue_provider.dart`:
|
||||||
|
- Uses enriched track data for download history
|
||||||
|
- Updated `lib/screens/track_metadata_screen.dart`:
|
||||||
|
- Show disc number when > 0 (was > 1)
|
||||||
|
- Updated `go_backend/metadata.go`:
|
||||||
|
- Added `TotalSamples` to `AudioQuality` struct for duration calculation
|
||||||
|
- Updated `go_backend/exports.go`:
|
||||||
|
- `ReadFileMetadata` now returns duration calculated from FLAC stream info
|
||||||
|
- Updated `AppTheme` with new `InputDecorationTheme` and `ButtonTheme` definitions
|
||||||
|
- Refactored `DownloadSettingsPage` to use new `_showFormatEditor` with cursor-aware capabilities
|
||||||
|
- Optimized various dialogs to use `showModalBottomSheet` with `isScrollControlled` for better keyboard handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.2.6] - 2026-01-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Release Mode Logging**: Flutter app logs now properly captured in release builds
|
||||||
|
- Previously only Go backend logs appeared when "Detailed Logging" was enabled
|
||||||
|
- Now both Flutter and Go logs are captured in release mode
|
||||||
|
- Bypasses Logger package which filters logs in release mode
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Detailed Deezer Search Logging**: Better debugging for search issues
|
||||||
|
- Logs API URLs, response counts, and errors
|
||||||
|
- Helps diagnose geo-restriction and API issues
|
||||||
|
- Detects Deezer API error responses
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Home Screen Logo**: Replaced music note icon with app logo
|
||||||
|
- Uses `assets/images/logo.png`
|
||||||
|
- Rounded corners (24px radius)
|
||||||
|
- Fallback to music note icon if logo fails to load
|
||||||
|
- **About Page Logo**: Removed shadow/border from logo
|
||||||
|
- Cleaner appearance without background container
|
||||||
|
- **About Page Icon Alignment**: Icons now aligned with contributor avatars
|
||||||
|
- DoubleDouble and DAB Music icons use 40x40 area
|
||||||
|
- Text now properly aligned with contributor items
|
||||||
|
|
||||||
## [2.2.5] - 2026-01-10
|
## [2.2.5] - 2026-01-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||||
[](https://www.virustotal.com/gui/file/cd205e22783a179aab80a2f0cc4445c84e59615a08c11d6e722ab4692c26ac37)
|
[](https://www.virustotal.com/gui/file/09c6260e9ebaf2ff0d15f30deda939642f41887f11aad602ac697cb37fa0308c/)
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
+76
-43
@@ -58,27 +58,27 @@ func GetDeezerClient() *DeezerClient {
|
|||||||
|
|
||||||
// Deezer API response types
|
// Deezer API response types
|
||||||
type deezerTrack struct {
|
type deezerTrack struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Duration int `json:"duration"` // in seconds
|
Duration int `json:"duration"` // in seconds
|
||||||
TrackPosition int `json:"track_position"`
|
TrackPosition int `json:"track_position"`
|
||||||
DiskNumber int `json:"disk_number"`
|
DiskNumber int `json:"disk_number"`
|
||||||
ISRC string `json:"isrc"`
|
ISRC string `json:"isrc"`
|
||||||
Link string `json:"link"`
|
Link string `json:"link"`
|
||||||
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
ReleaseDate string `json:"release_date"` // Sometimes at track level
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Album deezerAlbumSimple `json:"album"`
|
Album deezerAlbumSimple `json:"album"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerArtist struct {
|
type deezerArtist struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
PictureMedium string `json:"picture_medium"`
|
PictureMedium string `json:"picture_medium"`
|
||||||
PictureBig string `json:"picture_big"`
|
PictureBig string `json:"picture_big"`
|
||||||
PictureXL string `json:"picture_xl"`
|
PictureXL string `json:"picture_xl"`
|
||||||
NbFan int `json:"nb_fan"`
|
NbFan int `json:"nb_fan"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerAlbumSimple struct {
|
type deezerAlbumSimple struct {
|
||||||
@@ -90,6 +90,7 @@ type deezerAlbumSimple struct {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (skip other structs as they are fine/unchanged) ...
|
// ... (skip other structs as they are fine/unchanged) ...
|
||||||
|
|
||||||
// ... (in convertTrack) ...
|
// ... (in convertTrack) ...
|
||||||
@@ -137,17 +138,17 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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"`
|
||||||
Artist deezerArtist `json:"artist"`
|
Artist deezerArtist `json:"artist"`
|
||||||
Contributors []deezerArtist `json:"contributors"`
|
Contributors []deezerArtist `json:"contributors"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
@@ -164,17 +165,17 @@ type deezerArtistFull struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type deezerPlaylistFull struct {
|
type deezerPlaylistFull struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Picture string `json:"picture"`
|
Picture string `json:"picture"`
|
||||||
PictureMedium string `json:"picture_medium"`
|
PictureMedium string `json:"picture_medium"`
|
||||||
PictureBig string `json:"picture_big"`
|
PictureBig string `json:"picture_big"`
|
||||||
PictureXL string `json:"picture_xl"`
|
PictureXL string `json:"picture_xl"`
|
||||||
NbTracks int `json:"nb_tracks"`
|
NbTracks int `json:"nb_tracks"`
|
||||||
Creator struct {
|
Creator struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
} `json:"creator"`
|
} `json:"creator"`
|
||||||
Tracks struct {
|
Tracks struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
} `json:"tracks"`
|
} `json:"tracks"`
|
||||||
}
|
}
|
||||||
@@ -182,11 +183,14 @@ type deezerPlaylistFull struct {
|
|||||||
// SearchAll searches for tracks and artists on Deezer
|
// SearchAll searches for tracks and artists on Deezer
|
||||||
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
|
||||||
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
|
||||||
|
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
|
||||||
|
|
||||||
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()
|
||||||
|
GoLog("[Deezer] SearchAll: returning cached result\n")
|
||||||
return entry.data.(*SearchAllResult), nil
|
return entry.data.(*SearchAllResult), nil
|
||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
@@ -198,13 +202,28 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
|
|
||||||
// Search tracks - NO ISRC fetch for performance
|
// Search tracks - NO ISRC fetch for performance
|
||||||
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
|
||||||
|
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
|
||||||
|
|
||||||
var trackResp struct {
|
var trackResp struct {
|
||||||
Data []deezerTrack `json:"data"`
|
Data []deezerTrack `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
|
||||||
|
GoLog("[Deezer] Track search failed: %v\n", err)
|
||||||
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
return nil, fmt.Errorf("deezer track search failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if trackResp.Error != nil {
|
||||||
|
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
|
||||||
|
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
|
||||||
|
|
||||||
for _, track := range trackResp.Data {
|
for _, track := range trackResp.Data {
|
||||||
// Convert directly without fetching ISRC - much faster
|
// Convert directly without fetching ISRC - much faster
|
||||||
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
result.Tracks = append(result.Tracks, c.convertTrack(track))
|
||||||
@@ -212,21 +231,37 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
|
|
||||||
// Search artists
|
// Search artists
|
||||||
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
|
||||||
|
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
|
||||||
|
|
||||||
var artistResp struct {
|
var artistResp struct {
|
||||||
Data []deezerArtist `json:"data"`
|
Data []deezerArtist `json:"data"`
|
||||||
|
Error *struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
|
||||||
for _, artist := range artistResp.Data {
|
if artistResp.Error != nil {
|
||||||
result.Artists = append(result.Artists, SearchArtistResult{
|
GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
|
||||||
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
} else {
|
||||||
Name: artist.Name,
|
GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
|
||||||
Images: c.getBestArtistImage(artist),
|
for _, artist := range artistResp.Data {
|
||||||
Followers: artist.NbFan,
|
result.Artists = append(result.Artists, SearchArtistResult{
|
||||||
Popularity: 0,
|
ID: fmt.Sprintf("deezer:%d", artist.ID),
|
||||||
})
|
Name: artist.Name,
|
||||||
|
Images: c.getBestArtistImage(artist),
|
||||||
|
Followers: artist.NbFan,
|
||||||
|
Popularity: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
GoLog("[Deezer] Artist search failed: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
|
||||||
|
|
||||||
// Cache result
|
// Cache result
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.searchCache[cacheKey] = &cacheEntry{
|
c.searchCache[cacheKey] = &cacheEntry{
|
||||||
@@ -603,8 +638,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
return fullTrack.ISRC, nil
|
return fullTrack.ISRC, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
|
||||||
if artist.PictureXL != "" {
|
if artist.PictureXL != "" {
|
||||||
return artist.PictureXL
|
return artist.PictureXL
|
||||||
|
|||||||
@@ -525,6 +525,12 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
// Also get audio quality info
|
// Also get audio quality info
|
||||||
quality, qualityErr := GetAudioQuality(filePath)
|
quality, qualityErr := GetAudioQuality(filePath)
|
||||||
|
|
||||||
|
// Get duration from FLAC stream info
|
||||||
|
duration := 0
|
||||||
|
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
|
||||||
|
duration = int(quality.TotalSamples / int64(quality.SampleRate))
|
||||||
|
}
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"title": metadata.Title,
|
"title": metadata.Title,
|
||||||
"artist": metadata.Artist,
|
"artist": metadata.Artist,
|
||||||
@@ -535,6 +541,7 @@ func ReadFileMetadata(filePath string) (string, error) {
|
|||||||
"disc_number": metadata.DiscNumber,
|
"disc_number": metadata.DiscNumber,
|
||||||
"isrc": metadata.ISRC,
|
"isrc": metadata.ISRC,
|
||||||
"lyrics": metadata.Lyrics,
|
"lyrics": metadata.Lyrics,
|
||||||
|
"duration": duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add quality info if available
|
// Add quality info if available
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ func GetLogBuffer() *LogBuffer {
|
|||||||
globalLogBuffer = &LogBuffer{
|
globalLogBuffer = &LogBuffer{
|
||||||
entries: make([]LogEntry, 0, 500),
|
entries: make([]LogEntry, 0, 500),
|
||||||
maxSize: 500,
|
maxSize: 500,
|
||||||
loggingEnabled: false, // Default: disabled for performance
|
loggingEnabled: false, // Default: disabled for performance (user can enable in settings)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return globalLogBuffer
|
return globalLogBuffer
|
||||||
|
|||||||
+13
-5
@@ -400,8 +400,9 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
|
|
||||||
// AudioQuality represents audio quality info from a FLAC file
|
// AudioQuality represents audio quality info from a FLAC file
|
||||||
type AudioQuality struct {
|
type AudioQuality struct {
|
||||||
BitDepth int `json:"bit_depth"`
|
BitDepth int `json:"bit_depth"`
|
||||||
SampleRate int `json:"sample_rate"`
|
SampleRate int `json:"sample_rate"`
|
||||||
|
TotalSamples int64 `json:"total_samples"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
|
||||||
@@ -446,9 +447,17 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
// Parse bits per sample (5 bits)
|
// 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 |
|
||||||
|
int64(streamInfo[14])<<24 |
|
||||||
|
int64(streamInfo[15])<<16 |
|
||||||
|
int64(streamInfo[16])<<8 |
|
||||||
|
int64(streamInfo[17])
|
||||||
|
|
||||||
return AudioQuality{
|
return AudioQuality{
|
||||||
BitDepth: bitsPerSample,
|
BitDepth: bitsPerSample,
|
||||||
SampleRate: sampleRate,
|
SampleRate: sampleRate,
|
||||||
|
TotalSamples: totalSamples,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,7 +478,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// M4A (MP4/AAC) Metadata Embedding
|
// M4A (MP4/AAC) Metadata Embedding
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
+148
-1
@@ -638,7 +638,154 @@ type TidalDownloadInfo struct {
|
|||||||
SampleRate int
|
SampleRate int
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDownloadURLSequential requests download URL from APIs sequentially
|
// tidalAPIResult holds the result from a parallel API request
|
||||||
|
type tidalAPIResult struct {
|
||||||
|
apiURL string
|
||||||
|
info TidalDownloadInfo
|
||||||
|
err error
|
||||||
|
duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDownloadURLParallel requests download URL from all APIs in parallel
|
||||||
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
|
func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
|
if len(apis) == 0 {
|
||||||
|
return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available")
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis))
|
||||||
|
|
||||||
|
resultChan := make(chan tidalAPIResult, len(apis))
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Start all requests in parallel
|
||||||
|
for _, apiURL := range apis {
|
||||||
|
go func(api string) {
|
||||||
|
reqStart := time.Now()
|
||||||
|
|
||||||
|
// Create client with longer timeout for parallel requests
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality)
|
||||||
|
GoLog("[Tidal] [Parallel] Starting request to: %s\n", api)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Failed to create request: %v\n", api, err)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Request failed: %v\n", api, err)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - HTTP %d\n", api, resp.StatusCode)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Failed to read body: %v\n", api, err)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try v2 format first (object with manifest)
|
||||||
|
var v2Response TidalAPIResponseV2
|
||||||
|
if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" {
|
||||||
|
// IMPORTANT: Reject PREVIEW responses - we need FULL tracks
|
||||||
|
if v2Response.Data.AssetPresentation == "PREVIEW" {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Rejecting PREVIEW response\n", api)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Got FULL track (v2): %d-bit/%dHz in %v\n",
|
||||||
|
api, v2Response.Data.BitDepth, v2Response.Data.SampleRate, time.Since(reqStart))
|
||||||
|
|
||||||
|
info := TidalDownloadInfo{
|
||||||
|
URL: "MANIFEST:" + v2Response.Data.Manifest,
|
||||||
|
BitDepth: v2Response.Data.BitDepth,
|
||||||
|
SampleRate: v2Response.Data.SampleRate,
|
||||||
|
}
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to v1 format (array with OriginalTrackUrl)
|
||||||
|
var v1Responses []struct {
|
||||||
|
OriginalTrackURL string `json:"OriginalTrackUrl"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &v1Responses); err == nil {
|
||||||
|
for _, item := range v1Responses {
|
||||||
|
if item.OriginalTrackURL != "" {
|
||||||
|
GoLog("[Tidal] [Parallel] %s - Got direct URL (v1) in %v\n", api, time.Since(reqStart))
|
||||||
|
info := TidalDownloadInfo{
|
||||||
|
URL: item.OriginalTrackURL,
|
||||||
|
BitDepth: 16,
|
||||||
|
SampleRate: 44100,
|
||||||
|
}
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] [Parallel] %s - No download URL in response\n", api)
|
||||||
|
resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)}
|
||||||
|
}(apiURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect results - return first success
|
||||||
|
var errors []string
|
||||||
|
successCount := 0
|
||||||
|
failCount := 0
|
||||||
|
|
||||||
|
for i := 0; i < len(apis); i++ {
|
||||||
|
result := <-resultChan
|
||||||
|
if result.err == nil {
|
||||||
|
successCount++
|
||||||
|
if successCount == 1 {
|
||||||
|
// First success - use this one
|
||||||
|
GoLog("[Tidal] [Parallel] ✓ Using response from %s (took %v, total %v)\n",
|
||||||
|
result.apiURL, result.duration, time.Since(startTime))
|
||||||
|
|
||||||
|
// Don't return immediately - let other goroutines finish to avoid leaks
|
||||||
|
// But we'll use this result
|
||||||
|
go func() {
|
||||||
|
// Drain remaining results
|
||||||
|
for j := i + 1; j < len(apis); j++ {
|
||||||
|
<-resultChan
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return result.apiURL, result.info, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failCount++
|
||||||
|
errMsg := result.err.Error()
|
||||||
|
if len(errMsg) > 50 {
|
||||||
|
errMsg = errMsg[:50] + "..."
|
||||||
|
}
|
||||||
|
errors = append(errors, fmt.Sprintf("%s: %s", result.apiURL, errMsg))
|
||||||
|
GoLog("[Tidal] [Parallel] ✗ %s failed: %s (took %v)\n", result.apiURL, errMsg, result.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoLog("[Tidal] [Parallel] All %d APIs failed in %v\n", len(apis), time.Since(startTime))
|
||||||
|
return "", TidalDownloadInfo{}, fmt.Errorf("all %d Tidal APIs failed. Errors: %v", len(apis), errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDownloadURLSequential requests download URL from APIs sequentially (fallback)
|
||||||
// Returns the first successful result (supports both v1 and v2 API formats)
|
// Returns the first successful result (supports both v1 and v2 API formats)
|
||||||
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
func getDownloadURLSequential(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) {
|
||||||
if len(apis) == 0 {
|
if len(apis) == 0 {
|
||||||
|
|||||||
@@ -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 = '2.2.5';
|
static const String version = '2.2.7';
|
||||||
static const String buildNumber = '47';
|
static const String buildNumber = '49';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,26 +82,27 @@ class DownloadHistoryItem {
|
|||||||
'sampleRate': sampleRate,
|
'sampleRate': sampleRate,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem(
|
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) =>
|
||||||
id: json['id'] as String,
|
DownloadHistoryItem(
|
||||||
trackName: json['trackName'] as String,
|
id: json['id'] as String,
|
||||||
artistName: json['artistName'] as String,
|
trackName: json['trackName'] as String,
|
||||||
albumName: json['albumName'] as String,
|
artistName: json['artistName'] as String,
|
||||||
albumArtist: json['albumArtist'] as String?,
|
albumName: json['albumName'] as String,
|
||||||
coverUrl: json['coverUrl'] as String?,
|
albumArtist: json['albumArtist'] as String?,
|
||||||
filePath: json['filePath'] as String,
|
coverUrl: json['coverUrl'] as String?,
|
||||||
service: json['service'] as String,
|
filePath: json['filePath'] as String,
|
||||||
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
|
service: json['service'] as String,
|
||||||
isrc: json['isrc'] as String?,
|
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
|
||||||
spotifyId: json['spotifyId'] as String?,
|
isrc: json['isrc'] as String?,
|
||||||
trackNumber: json['trackNumber'] as int?,
|
spotifyId: json['spotifyId'] as String?,
|
||||||
discNumber: json['discNumber'] as int?,
|
trackNumber: json['trackNumber'] as int?,
|
||||||
duration: json['duration'] as int?,
|
discNumber: json['discNumber'] as int?,
|
||||||
releaseDate: json['releaseDate'] as String?,
|
duration: json['duration'] as int?,
|
||||||
quality: json['quality'] as String?,
|
releaseDate: json['releaseDate'] as String?,
|
||||||
bitDepth: json['bitDepth'] as int?,
|
quality: json['quality'] as String?,
|
||||||
sampleRate: json['sampleRate'] as int?,
|
bitDepth: json['bitDepth'] as int?,
|
||||||
);
|
sampleRate: json['sampleRate'] as int?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download History State
|
// Download History State
|
||||||
@@ -110,13 +111,14 @@ class DownloadHistoryState {
|
|||||||
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
|
||||||
|
|
||||||
DownloadHistoryState({this.items = const []})
|
DownloadHistoryState({this.items = const []})
|
||||||
: _downloadedSpotifyIds = items
|
: _downloadedSpotifyIds = items
|
||||||
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
||||||
.map((item) => item.spotifyId!)
|
.map((item) => item.spotifyId!)
|
||||||
.toSet();
|
.toSet();
|
||||||
|
|
||||||
/// Check if a track has been downloaded (by Spotify ID)
|
/// Check if a track has been downloaded (by Spotify ID)
|
||||||
bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId);
|
bool isDownloaded(String spotifyId) =>
|
||||||
|
_downloadedSpotifyIds.contains(spotifyId);
|
||||||
|
|
||||||
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
|
||||||
return DownloadHistoryState(items: items ?? this.items);
|
return DownloadHistoryState(items: items ?? this.items);
|
||||||
@@ -150,7 +152,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
final jsonStr = prefs.getString(_storageKey);
|
final jsonStr = prefs.getString(_storageKey);
|
||||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList();
|
final items = jsonList
|
||||||
|
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_historyLog.i('Loaded ${items.length} items from storage');
|
_historyLog.i('Loaded ${items.length} items from storage');
|
||||||
} else {
|
} else {
|
||||||
@@ -210,9 +214,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Download History Provider
|
// Download History Provider
|
||||||
final downloadHistoryProvider = NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
|
final downloadHistoryProvider =
|
||||||
DownloadHistoryNotifier.new,
|
NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
|
||||||
);
|
DownloadHistoryNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
class DownloadQueueState {
|
class DownloadQueueState {
|
||||||
final List<DownloadItem> items;
|
final List<DownloadItem> items;
|
||||||
@@ -261,10 +266,19 @@ class DownloadQueueState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
int get queuedCount => items.where((i) => i.status == DownloadStatus.queued || i.status == DownloadStatus.downloading).length;
|
int get queuedCount => items
|
||||||
int get completedCount => items.where((i) => i.status == DownloadStatus.completed).length;
|
.where(
|
||||||
int get failedCount => items.where((i) => i.status == DownloadStatus.failed).length;
|
(i) =>
|
||||||
int get activeDownloadsCount => items.where((i) => i.status == DownloadStatus.downloading).length;
|
i.status == DownloadStatus.queued ||
|
||||||
|
i.status == DownloadStatus.downloading,
|
||||||
|
)
|
||||||
|
.length;
|
||||||
|
int get completedCount =>
|
||||||
|
items.where((i) => i.status == DownloadStatus.completed).length;
|
||||||
|
int get failedCount =>
|
||||||
|
items.where((i) => i.status == DownloadStatus.failed).length;
|
||||||
|
int get activeDownloadsCount =>
|
||||||
|
items.where((i) => i.status == DownloadStatus.downloading).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download Queue Notifier (Riverpod 3.x)
|
// Download Queue Notifier (Riverpod 3.x)
|
||||||
@@ -272,7 +286,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Timer? _progressTimer;
|
Timer? _progressTimer;
|
||||||
int _downloadCount = 0; // Counter for connection cleanup
|
int _downloadCount = 0; // Counter for connection cleanup
|
||||||
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
static const _cleanupInterval = 50; // Cleanup every 50 downloads
|
||||||
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
static const _queueStorageKey =
|
||||||
|
'download_queue'; // Storage key for queue persistence
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
int _totalQueuedAtStart = 0; // Track total items when queue started
|
||||||
int _completedInSession = 0; // Track completed downloads in current session
|
int _completedInSession = 0; // Track completed downloads in current session
|
||||||
@@ -305,7 +320,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final jsonStr = prefs.getString(_queueStorageKey);
|
final jsonStr = prefs.getString(_queueStorageKey);
|
||||||
if (jsonStr != null && jsonStr.isNotEmpty) {
|
if (jsonStr != null && jsonStr.isNotEmpty) {
|
||||||
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
final List<dynamic> jsonList = jsonDecode(jsonStr);
|
||||||
final items = jsonList.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>)).toList();
|
final items = jsonList
|
||||||
|
.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Reset downloading items to queued (they were interrupted)
|
// Reset downloading items to queued (they were interrupted)
|
||||||
final restoredItems = items.map((item) {
|
final restoredItems = items.map((item) {
|
||||||
@@ -316,9 +333,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
// Only restore queued/downloading items (not completed/failed/skipped)
|
// Only restore queued/downloading items (not completed/failed/skipped)
|
||||||
final pendingItems = restoredItems.where((item) =>
|
final pendingItems = restoredItems
|
||||||
item.status == DownloadStatus.queued
|
.where((item) => item.status == DownloadStatus.queued)
|
||||||
).toList();
|
.toList();
|
||||||
|
|
||||||
if (pendingItems.isNotEmpty) {
|
if (pendingItems.isNotEmpty) {
|
||||||
state = state.copyWith(items: pendingItems);
|
state = state.copyWith(items: pendingItems);
|
||||||
@@ -345,10 +362,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Only persist queued and downloading items
|
// Only persist queued and downloading items
|
||||||
final pendingItems = state.items.where((item) =>
|
final pendingItems = state.items
|
||||||
item.status == DownloadStatus.queued ||
|
.where(
|
||||||
item.status == DownloadStatus.downloading
|
(item) =>
|
||||||
).toList();
|
item.status == DownloadStatus.queued ||
|
||||||
|
item.status == DownloadStatus.downloading,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (pendingItems.isEmpty) {
|
if (pendingItems.isEmpty) {
|
||||||
// Clear storage if no pending items
|
// Clear storage if no pending items
|
||||||
@@ -367,7 +387,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
/// Start multi-progress polling for all downloads (sequential and parallel)
|
/// Start multi-progress polling for all downloads (sequential and parallel)
|
||||||
void _startMultiProgressPolling() {
|
void _startMultiProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (
|
||||||
|
timer,
|
||||||
|
) async {
|
||||||
try {
|
try {
|
||||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||||
@@ -381,8 +403,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final itemProgress = entry.value as Map<String, dynamic>;
|
final itemProgress = entry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
|
||||||
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
|
final speedMBps =
|
||||||
final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
|
(itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final isDownloading =
|
||||||
|
itemProgress['is_downloading'] as bool? ?? false;
|
||||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
// Check if status is "finalizing" (embedding metadata)
|
// Check if status is "finalizing" (embedding metadata)
|
||||||
@@ -391,7 +415,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
|
||||||
|
|
||||||
// Track finalizing item for notification
|
// Track finalizing item for notification
|
||||||
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
final currentItem = state.items
|
||||||
|
.where((i) => i.id == itemId)
|
||||||
|
.firstOrNull;
|
||||||
if (currentItem != null) {
|
if (currentItem != null) {
|
||||||
hasFinalizingItem = true;
|
hasFinalizingItem = true;
|
||||||
finalizingTrackName = currentItem.track.name;
|
finalizingTrackName = currentItem.track.name;
|
||||||
@@ -401,16 +427,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use progress from backend if available (handles both explicit progress and byte-based)
|
// Use progress from backend if available (handles both explicit progress and byte-based)
|
||||||
final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
final progressFromBackend =
|
||||||
|
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
|
||||||
if (isDownloading) {
|
if (isDownloading) {
|
||||||
double percentage = 0.0;
|
double percentage = 0.0;
|
||||||
if (bytesTotal > 0) {
|
if (bytesTotal > 0) {
|
||||||
// Calculate from bytes if available for precision
|
// Calculate from bytes if available for precision
|
||||||
percentage = bytesReceived / bytesTotal;
|
percentage = bytesReceived / bytesTotal;
|
||||||
} else {
|
} else {
|
||||||
// Fallback to backend-reported progress (e.g. for DASH segments)
|
// Fallback to backend-reported progress (e.g. for DASH segments)
|
||||||
percentage = progressFromBackend;
|
percentage = progressFromBackend;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProgress(itemId, percentage, speedMBps: speedMBps);
|
updateProgress(itemId, percentage, speedMBps: speedMBps);
|
||||||
@@ -419,9 +446,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final mbReceived = bytesReceived / (1024 * 1024);
|
final mbReceived = bytesReceived / (1024 * 1024);
|
||||||
final mbTotal = bytesTotal / (1024 * 1024);
|
final mbTotal = bytesTotal / (1024 * 1024);
|
||||||
if (bytesTotal > 0) {
|
if (bytesTotal > 0) {
|
||||||
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s');
|
_log.d(
|
||||||
|
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s');
|
_log.d(
|
||||||
|
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -443,7 +474,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||||
|
|
||||||
// Find downloading items (not finalizing)
|
// Find downloading items (not finalizing)
|
||||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
final downloadingItems = state.items
|
||||||
|
.where((i) => i.status == DownloadStatus.downloading)
|
||||||
|
.toList();
|
||||||
if (downloadingItems.isNotEmpty) {
|
if (downloadingItems.isNotEmpty) {
|
||||||
// Show single track name if only 1 download, otherwise show count
|
// Show single track name if only 1 download, otherwise show count
|
||||||
final trackName = downloadingItems.length == 1
|
final trackName = downloadingItems.length == 1
|
||||||
@@ -459,7 +492,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
if (bytesTotal <= 0) {
|
if (bytesTotal <= 0) {
|
||||||
// Fallback to percentage for DASH/unknown size
|
// Fallback to percentage for DASH/unknown size
|
||||||
final progressPercent = (firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
final progressPercent =
|
||||||
|
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
|
||||||
notifProgress = (progressPercent * 100).toInt();
|
notifProgress = (progressPercent * 100).toInt();
|
||||||
notifTotal = 100;
|
notifTotal = 100;
|
||||||
}
|
}
|
||||||
@@ -509,7 +543,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Android: Use external storage Music folder
|
// Android: Use external storage Music folder
|
||||||
final dir = await getExternalStorageDirectory();
|
final dir = await getExternalStorageDirectory();
|
||||||
if (dir != null) {
|
if (dir != null) {
|
||||||
final musicDir = Directory('${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC');
|
final musicDir = Directory(
|
||||||
|
'${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
|
||||||
|
);
|
||||||
if (!await musicDir.exists()) {
|
if (!await musicDir.exists()) {
|
||||||
await musicDir.create(recursive: true);
|
await musicDir.create(recursive: true);
|
||||||
}
|
}
|
||||||
@@ -588,7 +624,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
void updateSettings(AppSettings settings) {
|
void updateSettings(AppSettings settings) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir,
|
outputDir: settings.downloadDirectory.isNotEmpty
|
||||||
|
? settings.downloadDirectory
|
||||||
|
: state.outputDir,
|
||||||
filenameFormat: settings.filenameFormat,
|
filenameFormat: settings.filenameFormat,
|
||||||
audioQuality: settings.audioQuality,
|
audioQuality: settings.audioQuality,
|
||||||
autoFallback: settings.autoFallback,
|
autoFallback: settings.autoFallback,
|
||||||
@@ -601,7 +639,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
|
|
||||||
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
final id =
|
||||||
|
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final item = DownloadItem(
|
final item = DownloadItem(
|
||||||
id: id,
|
id: id,
|
||||||
track: track,
|
track: track,
|
||||||
@@ -621,13 +660,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
void addMultipleToQueue(List<Track> tracks, String service, {String? qualityOverride}) {
|
void addMultipleToQueue(
|
||||||
|
List<Track> tracks,
|
||||||
|
String service, {
|
||||||
|
String? qualityOverride,
|
||||||
|
}) {
|
||||||
// Sync settings before adding to queue
|
// Sync settings before adding to queue
|
||||||
final settings = ref.read(settingsProvider);
|
final settings = ref.read(settingsProvider);
|
||||||
updateSettings(settings);
|
updateSettings(settings);
|
||||||
|
|
||||||
final newItems = tracks.map((track) {
|
final newItems = tracks.map((track) {
|
||||||
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
final id =
|
||||||
|
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
return DownloadItem(
|
return DownloadItem(
|
||||||
id: id,
|
id: id,
|
||||||
track: track,
|
track: track,
|
||||||
@@ -646,7 +690,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateItemStatus(String id, DownloadStatus status, {double? progress, double? speedMBps, String? filePath, String? error, DownloadErrorType? errorType}) {
|
void updateItemStatus(
|
||||||
|
String id,
|
||||||
|
DownloadStatus status, {
|
||||||
|
double? progress,
|
||||||
|
double? speedMBps,
|
||||||
|
String? filePath,
|
||||||
|
String? error,
|
||||||
|
DownloadErrorType? errorType,
|
||||||
|
}) {
|
||||||
final items = state.items.map((item) {
|
final items = state.items.map((item) {
|
||||||
if (item.id == id) {
|
if (item.id == id) {
|
||||||
return item.copyWith(
|
return item.copyWith(
|
||||||
@@ -672,7 +724,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void updateProgress(String id, double progress, {double? speedMBps}) {
|
void updateProgress(String id, double progress, {double? speedMBps}) {
|
||||||
updateItemStatus(id, DownloadStatus.downloading, progress: progress, speedMBps: speedMBps);
|
updateItemStatus(
|
||||||
|
id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: progress,
|
||||||
|
speedMBps: speedMBps,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelItem(String id) {
|
void cancelItem(String id) {
|
||||||
@@ -680,11 +737,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void clearCompleted() {
|
void clearCompleted() {
|
||||||
final items = state.items.where((item) =>
|
final items = state.items
|
||||||
item.status != DownloadStatus.completed &&
|
.where(
|
||||||
item.status != DownloadStatus.failed &&
|
(item) =>
|
||||||
item.status != DownloadStatus.skipped
|
item.status != DownloadStatus.completed &&
|
||||||
).toList();
|
item.status != DownloadStatus.failed &&
|
||||||
|
item.status != DownloadStatus.skipped,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
state = state.copyWith(items: items);
|
state = state.copyWith(items: items);
|
||||||
_saveQueueToStorage(); // Persist queue
|
_saveQueueToStorage(); // Persist queue
|
||||||
@@ -734,7 +794,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only retry if status is failed or skipped
|
// Only retry if status is failed or skipped
|
||||||
if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) {
|
if (item.status != DownloadStatus.failed &&
|
||||||
|
item.status != DownloadStatus.skipped) {
|
||||||
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -743,7 +804,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
final items = state.items.map((i) {
|
final items = state.items.map((i) {
|
||||||
if (i.id == id) {
|
if (i.id == id) {
|
||||||
return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
|
return i.copyWith(
|
||||||
|
status: DownloadStatus.queued,
|
||||||
|
progress: 0,
|
||||||
|
error: null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return i;
|
return i;
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -774,7 +839,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
final uniqueId =
|
||||||
|
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||||
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
|
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
|
||||||
|
|
||||||
// Download cover using HTTP
|
// Download cover using HTTP
|
||||||
@@ -852,7 +918,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_log.w('Failed to fetch lyrics for embedding: $e');
|
_log.w('Failed to fetch lyrics for embedding: $e');
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.d('Generating tags for FLAC: $metadata');
|
_log.d('Generating tags for FLAC: $metadata');
|
||||||
@@ -861,7 +927,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Note: FFmpegService.embedMetadata handles safe temp file creation
|
// Note: FFmpegService.embedMetadata handles safe temp file creation
|
||||||
final result = await FFmpegService.embedMetadata(
|
final result = await FFmpegService.embedMetadata(
|
||||||
flacPath: flacPath,
|
flacPath: flacPath,
|
||||||
coverPath: coverPath != null && await File(coverPath).exists() ? coverPath : null,
|
coverPath: coverPath != null && await File(coverPath).exists()
|
||||||
|
? coverPath
|
||||||
|
: null,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -876,9 +944,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
try {
|
try {
|
||||||
final coverFile = File(coverPath);
|
final coverFile = File(coverPath);
|
||||||
if (await coverFile.exists()) {
|
if (await coverFile.exists()) {
|
||||||
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
|
// In Android 10+ scoped storage, we can't easily delete if we didn't create it
|
||||||
// in this session or if it's not in our app dir.
|
// in this session or if it's not in our app dir.
|
||||||
// But coverPath is typically in temp dir now.
|
// But coverPath is typically in temp dir now.
|
||||||
await coverFile.delete();
|
await coverFile.delete();
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -895,7 +963,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.i('Starting queue processing...');
|
_log.i('Starting queue processing...');
|
||||||
|
|
||||||
// Track total items at start for notification
|
// Track total items at start for notification
|
||||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
_totalQueuedAtStart = state.items
|
||||||
|
.where((i) => i.status == DownloadStatus.queued)
|
||||||
|
.length;
|
||||||
_completedInSession = 0;
|
_completedInSession = 0;
|
||||||
_failedInSession = 0;
|
_failedInSession = 0;
|
||||||
|
|
||||||
@@ -968,7 +1038,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show queue completion notification
|
// Show queue completion notification
|
||||||
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart');
|
_log.i(
|
||||||
|
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
||||||
|
);
|
||||||
if (_totalQueuedAtStart > 0) {
|
if (_totalQueuedAtStart > 0) {
|
||||||
await _notificationService.showQueueComplete(
|
await _notificationService.showQueueComplete(
|
||||||
completedCount: _completedInSession,
|
completedCount: _completedInSession,
|
||||||
@@ -980,9 +1052,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||||
|
|
||||||
// Check if there are new queued items (e.g., from retry) and restart if needed
|
// Check if there are new queued items (e.g., from retry) and restart if needed
|
||||||
final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued);
|
final hasQueuedItems = state.items.any(
|
||||||
|
(item) => item.status == DownloadStatus.queued,
|
||||||
|
);
|
||||||
if (hasQueuedItems) {
|
if (hasQueuedItems) {
|
||||||
_log.i('Found queued items after processing finished, restarting queue...');
|
_log.i(
|
||||||
|
'Found queued items after processing finished, restarting queue...',
|
||||||
|
);
|
||||||
Future.microtask(() => _processQueue());
|
Future.microtask(() => _processQueue());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1006,18 +1082,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
(item) => item.status == DownloadStatus.queued,
|
(item) => item.status == DownloadStatus.queued,
|
||||||
orElse: () => DownloadItem(
|
orElse: () => DownloadItem(
|
||||||
id: '',
|
id: '',
|
||||||
track: const Track(id: '', name: '', artistName: '', albumName: '', duration: 0),
|
track: const Track(
|
||||||
|
id: '',
|
||||||
|
name: '',
|
||||||
|
artistName: '',
|
||||||
|
albumName: '',
|
||||||
|
duration: 0,
|
||||||
|
),
|
||||||
service: '',
|
service: '',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (nextItem.id.isEmpty) {
|
if (nextItem.id.isEmpty) {
|
||||||
_log.d('No more items to process (checked ${currentItems.length} items)');
|
_log.d(
|
||||||
|
'No more items to process (checked ${currentItems.length} items)',
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})');
|
_log.d(
|
||||||
|
'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})',
|
||||||
|
);
|
||||||
await _downloadSingleItem(nextItem);
|
await _downloadSingleItem(nextItem);
|
||||||
|
|
||||||
// Clear item progress after download completes
|
// Clear item progress after download completes
|
||||||
@@ -1049,7 +1135,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get queued items
|
// Get queued items
|
||||||
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList();
|
final queuedItems = state.items
|
||||||
|
.where((item) => item.status == DownloadStatus.queued)
|
||||||
|
.toList();
|
||||||
|
|
||||||
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
||||||
_log.d('No more items to process');
|
_log.d('No more items to process');
|
||||||
@@ -1057,7 +1145,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start new downloads up to max concurrent limit
|
// Start new downloads up to max concurrent limit
|
||||||
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty && !state.isPaused) {
|
while (activeDownloads.length < maxConcurrent &&
|
||||||
|
queuedItems.isNotEmpty &&
|
||||||
|
!state.isPaused) {
|
||||||
final item = queuedItems.removeAt(0);
|
final item = queuedItems.removeAt(0);
|
||||||
|
|
||||||
// Mark as downloading immediately to prevent double-processing
|
// Mark as downloading immediately to prevent double-processing
|
||||||
@@ -1071,7 +1161,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
activeDownloads[item.id] = future;
|
activeDownloads[item.id] = future;
|
||||||
_log.d('Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)');
|
_log.d(
|
||||||
|
'Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for at least one download to complete before checking for more
|
// Wait for at least one download to complete before checking for more
|
||||||
@@ -1109,47 +1201,65 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
Track trackToDownload = item.track;
|
Track trackToDownload = item.track;
|
||||||
// Enrich metadata if ISRC or track number is missing (common from Search results)
|
// Enrich metadata if ISRC or track number is missing (common from Search results)
|
||||||
// ISRC is critical for accurate track matching on streaming services
|
// ISRC is critical for accurate track matching on streaming services
|
||||||
final needsEnrichment = trackToDownload.id.startsWith('deezer:') &&
|
final needsEnrichment =
|
||||||
(trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty ||
|
trackToDownload.id.startsWith('deezer:') &&
|
||||||
trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0);
|
(trackToDownload.isrc == null ||
|
||||||
|
trackToDownload.isrc!.isEmpty ||
|
||||||
|
trackToDownload.trackNumber == null ||
|
||||||
|
trackToDownload.trackNumber == 0);
|
||||||
|
|
||||||
if (needsEnrichment) {
|
if (needsEnrichment) {
|
||||||
try {
|
try {
|
||||||
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}');
|
_log.d(
|
||||||
_log.d('Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}');
|
'Enriching incomplete metadata for Deezer track: ${trackToDownload.name}',
|
||||||
|
);
|
||||||
|
_log.d(
|
||||||
|
'Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}',
|
||||||
|
);
|
||||||
final rawId = trackToDownload.id.split(':')[1];
|
final rawId = trackToDownload.id.split(':')[1];
|
||||||
_log.d('Fetching full metadata for Deezer ID: $rawId');
|
_log.d('Fetching full metadata for Deezer ID: $rawId');
|
||||||
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId);
|
final fullData = await PlatformBridge.getDeezerMetadata(
|
||||||
|
'track',
|
||||||
|
rawId,
|
||||||
|
);
|
||||||
_log.d('Got response keys: ${fullData.keys.toList()}');
|
_log.d('Got response keys: ${fullData.keys.toList()}');
|
||||||
|
|
||||||
if (fullData.containsKey('track')) {
|
if (fullData.containsKey('track')) {
|
||||||
// Parse Go backend response (snake_case) to Track
|
// Parse Go backend response (snake_case) to Track
|
||||||
final trackData = fullData['track'];
|
final trackData = fullData['track'];
|
||||||
_log.d('Track data type: ${trackData.runtimeType}');
|
_log.d('Track data type: ${trackData.runtimeType}');
|
||||||
if (trackData is Map<String, dynamic>) {
|
if (trackData is Map<String, dynamic>) {
|
||||||
final data = trackData;
|
final data = trackData;
|
||||||
_log.d('Track data keys: ${data.keys.toList()}');
|
_log.d('Track data keys: ${data.keys.toList()}');
|
||||||
_log.d('ISRC from API: ${data['isrc']}');
|
_log.d('ISRC from API: ${data['isrc']}');
|
||||||
trackToDownload = Track(
|
trackToDownload = Track(
|
||||||
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
||||||
name: (data['name'] as String?) ?? trackToDownload.name,
|
name: (data['name'] as String?) ?? trackToDownload.name,
|
||||||
artistName: (data['artists'] as String?) ?? trackToDownload.artistName,
|
artistName:
|
||||||
albumName: (data['album_name'] as String?) ?? trackToDownload.albumName,
|
(data['artists'] as String?) ?? trackToDownload.artistName,
|
||||||
albumArtist: data['album_artist'] as String?,
|
albumName:
|
||||||
coverUrl: data['images'] as String?,
|
(data['album_name'] as String?) ??
|
||||||
// duration_ms from Go is in milliseconds, Track.duration is in seconds
|
trackToDownload.albumName,
|
||||||
duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ 1000,
|
albumArtist: data['album_artist'] as String?,
|
||||||
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
|
coverUrl: data['images'] as String?,
|
||||||
trackNumber: data['track_number'] as int?,
|
// duration_ms from Go is in milliseconds, Track.duration is in seconds
|
||||||
discNumber: data['disc_number'] as int?,
|
duration:
|
||||||
releaseDate: data['release_date'] as String?,
|
((data['duration_ms'] as int?) ??
|
||||||
deezerId: rawId,
|
(trackToDownload.duration * 1000)) ~/
|
||||||
availability: trackToDownload.availability,
|
1000,
|
||||||
);
|
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
|
||||||
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}');
|
trackNumber: data['track_number'] as int?,
|
||||||
} else {
|
discNumber: data['disc_number'] as int?,
|
||||||
_log.w('Unexpected track data type: ${trackData.runtimeType}');
|
releaseDate: data['release_date'] as String?,
|
||||||
}
|
deezerId: rawId,
|
||||||
|
availability: trackToDownload.availability,
|
||||||
|
);
|
||||||
|
_log.d(
|
||||||
|
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_log.w('Unexpected track data type: ${trackData.runtimeType}');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.w('Response does not contain track key');
|
_log.w('Response does not contain track key');
|
||||||
}
|
}
|
||||||
@@ -1159,7 +1269,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final outputDir = await _buildOutputDir(trackToDownload, settings.folderOrganization);
|
// Log cover URL for debugging CSV import issues
|
||||||
|
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
||||||
|
|
||||||
|
final outputDir = await _buildOutputDir(
|
||||||
|
trackToDownload,
|
||||||
|
settings.folderOrganization,
|
||||||
|
);
|
||||||
|
|
||||||
// Use quality override if set, otherwise use default from settings
|
// Use quality override if set, otherwise use default from settings
|
||||||
final quality = item.qualityOverride ?? state.audioQuality;
|
final quality = item.qualityOverride ?? state.audioQuality;
|
||||||
@@ -1168,7 +1284,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
if (state.autoFallback) {
|
if (state.autoFallback) {
|
||||||
_log.d('Using auto-fallback mode');
|
_log.d('Using auto-fallback mode');
|
||||||
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}');
|
_log.d(
|
||||||
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
||||||
|
);
|
||||||
_log.d('Output dir: $outputDir');
|
_log.d('Output dir: $outputDir');
|
||||||
result = await PlatformBridge.downloadWithFallback(
|
result = await PlatformBridge.downloadWithFallback(
|
||||||
isrc: trackToDownload.isrc ?? '',
|
isrc: trackToDownload.isrc ?? '',
|
||||||
@@ -1186,7 +1304,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
preferredService: item.service,
|
preferredService: item.service,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
durationMs: trackToDownload.duration, // Duration in ms for verification
|
durationMs:
|
||||||
|
trackToDownload.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await PlatformBridge.downloadTrack(
|
result = await PlatformBridge.downloadTrack(
|
||||||
@@ -1205,14 +1324,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
discNumber: trackToDownload.discNumber ?? 1,
|
discNumber: trackToDownload.discNumber ?? 1,
|
||||||
releaseDate: trackToDownload.releaseDate,
|
releaseDate: trackToDownload.releaseDate,
|
||||||
itemId: item.id, // Pass item ID for progress tracking
|
itemId: item.id, // Pass item ID for progress tracking
|
||||||
durationMs: trackToDownload.duration, // Duration in ms for verification
|
durationMs:
|
||||||
|
trackToDownload.duration, // Duration in ms for verification
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.d('Result: $result');
|
_log.d('Result: $result');
|
||||||
|
|
||||||
// Check if item was cancelled while downloading
|
// Check if item was cancelled while downloading
|
||||||
final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
final currentItem = state.items.firstWhere(
|
||||||
|
(i) => i.id == item.id,
|
||||||
|
orElse: () => item,
|
||||||
|
);
|
||||||
if (currentItem.status == DownloadStatus.skipped) {
|
if (currentItem.status == DownloadStatus.skipped) {
|
||||||
_log.i('Download was cancelled, skipping result processing');
|
_log.i('Download was cancelled, skipping result processing');
|
||||||
// Delete the downloaded file if it exists
|
// Delete the downloaded file if it exists
|
||||||
@@ -1243,7 +1366,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (actualBitDepth != null && actualBitDepth > 0) {
|
if (actualBitDepth != null && actualBitDepth > 0) {
|
||||||
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
|
||||||
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
||||||
? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1)
|
? (actualSampleRate / 1000).toStringAsFixed(
|
||||||
|
actualSampleRate % 1000 == 0 ? 0 : 1,
|
||||||
|
)
|
||||||
: '?';
|
: '?';
|
||||||
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
|
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
|
||||||
_log.i('Actual quality: $actualQuality');
|
_log.i('Actual quality: $actualQuality');
|
||||||
@@ -1252,20 +1377,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||||
_log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...');
|
_log.d(
|
||||||
|
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final file = File(filePath);
|
final file = File(filePath);
|
||||||
if (!await file.exists()) {
|
if (!await file.exists()) {
|
||||||
_log.e('File does not exist at path: $filePath');
|
_log.e('File does not exist at path: $filePath');
|
||||||
} else {
|
} else {
|
||||||
final length = await file.length();
|
final length = await file.length();
|
||||||
_log.i('File size before conversion: ${length / 1024} KB');
|
_log.i('File size before conversion: ${length / 1024} KB');
|
||||||
|
|
||||||
if (length < 1024) {
|
if (length < 1024) {
|
||||||
_log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.');
|
_log.w(
|
||||||
|
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95);
|
updateItemStatus(
|
||||||
|
item.id,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
progress: 0.95,
|
||||||
|
);
|
||||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
||||||
|
|
||||||
if (flacPath != null) {
|
if (flacPath != null) {
|
||||||
@@ -1278,36 +1411,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Update track with actual metadata from backend result (if available)
|
// Update track with actual metadata from backend result (if available)
|
||||||
// This creates the most accurate metadata possible (from the service itself)
|
// This creates the most accurate metadata possible (from the service itself)
|
||||||
Track finalTrack = trackToDownload;
|
Track finalTrack = trackToDownload;
|
||||||
if (result.containsKey('track_number') || result.containsKey('release_date')) {
|
if (result.containsKey('track_number') ||
|
||||||
_log.d('Using metadata from backend response for embedding');
|
result.containsKey('release_date')) {
|
||||||
final backendTrackNum = result['track_number'] as int?;
|
_log.d(
|
||||||
final backendDiscNum = result['disc_number'] as int?;
|
'Using metadata from backend response for embedding',
|
||||||
final backendYear = result['release_date'] as String?;
|
);
|
||||||
final backendAlbum = result['album'] as String?;
|
final backendTrackNum = result['track_number'] as int?;
|
||||||
|
final backendDiscNum = result['disc_number'] as int?;
|
||||||
|
final backendYear = result['release_date'] as String?;
|
||||||
|
final backendAlbum = result['album'] as String?;
|
||||||
|
|
||||||
_log.d('Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear');
|
_log.d(
|
||||||
|
'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear',
|
||||||
|
);
|
||||||
|
|
||||||
// Create updated track object with safety check for 0/null
|
// Create updated track object with safety check for 0/null
|
||||||
final newTrackNumber = (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber;
|
final newTrackNumber =
|
||||||
final newDiscNumber = (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber;
|
(backendTrackNum != null && backendTrackNum > 0)
|
||||||
|
? backendTrackNum
|
||||||
|
: trackToDownload.trackNumber;
|
||||||
|
final newDiscNumber =
|
||||||
|
(backendDiscNum != null && backendDiscNum > 0)
|
||||||
|
? backendDiscNum
|
||||||
|
: trackToDownload.discNumber;
|
||||||
|
|
||||||
_log.d('Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber');
|
_log.d(
|
||||||
|
'Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber',
|
||||||
|
);
|
||||||
|
|
||||||
finalTrack = Track(
|
finalTrack = Track(
|
||||||
id: trackToDownload.id,
|
id: trackToDownload.id,
|
||||||
name: trackToDownload.name,
|
name: trackToDownload.name,
|
||||||
artistName: trackToDownload.artistName,
|
artistName: trackToDownload.artistName,
|
||||||
albumName: backendAlbum ?? trackToDownload.albumName,
|
albumName: backendAlbum ?? trackToDownload.albumName,
|
||||||
albumArtist: trackToDownload.albumArtist,
|
albumArtist: trackToDownload.albumArtist,
|
||||||
coverUrl: trackToDownload.coverUrl,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
duration: trackToDownload.duration,
|
duration: trackToDownload.duration,
|
||||||
isrc: trackToDownload.isrc,
|
isrc: trackToDownload.isrc,
|
||||||
trackNumber: newTrackNumber,
|
trackNumber: newTrackNumber,
|
||||||
discNumber: newDiscNumber,
|
discNumber: newDiscNumber,
|
||||||
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
releaseDate: backendYear ?? trackToDownload.releaseDate,
|
||||||
deezerId: trackToDownload.deezerId,
|
deezerId: trackToDownload.deezerId,
|
||||||
availability: trackToDownload.availability,
|
availability: trackToDownload.availability,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use enriched/updated track for metadata embedding
|
// Use enriched/updated track for metadata embedding
|
||||||
@@ -1328,7 +1474,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check again if cancelled before updating status and adding to history
|
// Check again if cancelled before updating status and adding to history
|
||||||
final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item);
|
final itemAfterDownload = state.items.firstWhere(
|
||||||
|
(i) => i.id == item.id,
|
||||||
|
orElse: () => item,
|
||||||
|
);
|
||||||
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
if (itemAfterDownload.status == DownloadStatus.skipped) {
|
||||||
_log.i('Download was cancelled during finalization, cleaning up');
|
_log.i('Download was cancelled during finalization, cleaning up');
|
||||||
// Delete the downloaded file
|
// Delete the downloaded file
|
||||||
@@ -1365,7 +1514,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
// Extract updated metadata from backend result if available
|
// Extract metadata from backend result (most accurate source)
|
||||||
final backendTitle = result['title'] as String?;
|
final backendTitle = result['title'] as String?;
|
||||||
final backendArtist = result['artist'] as String?;
|
final backendArtist = result['artist'] as String?;
|
||||||
final backendAlbum = result['album'] as String?;
|
final backendAlbum = result['album'] as String?;
|
||||||
@@ -1376,29 +1525,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final backendSampleRate = result['actual_sample_rate'] as int?;
|
final backendSampleRate = result['actual_sample_rate'] as int?;
|
||||||
final backendISRC = result['isrc'] as String?;
|
final backendISRC = result['isrc'] as String?;
|
||||||
|
|
||||||
ref.read(downloadHistoryProvider.notifier).addToHistory(
|
// Log cover URL for debugging
|
||||||
DownloadHistoryItem(
|
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
||||||
id: item.id,
|
|
||||||
trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : item.track.name,
|
ref
|
||||||
artistName: (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : item.track.artistName,
|
.read(downloadHistoryProvider.notifier)
|
||||||
albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : item.track.albumName,
|
.addToHistory(
|
||||||
albumArtist: item.track.albumArtist,
|
DownloadHistoryItem(
|
||||||
coverUrl: item.track.coverUrl,
|
id: item.id,
|
||||||
filePath: filePath,
|
trackName: (backendTitle != null && backendTitle.isNotEmpty)
|
||||||
service: result['service'] as String? ?? item.service,
|
? backendTitle
|
||||||
downloadedAt: DateTime.now(),
|
: trackToDownload.name,
|
||||||
// Additional metadata
|
artistName: (backendArtist != null && backendArtist.isNotEmpty)
|
||||||
isrc: (backendISRC != null && backendISRC.isNotEmpty) ? backendISRC : item.track.isrc,
|
? backendArtist
|
||||||
spotifyId: item.track.id,
|
: trackToDownload.artistName,
|
||||||
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber,
|
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
|
||||||
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber,
|
? backendAlbum
|
||||||
duration: item.track.duration,
|
: trackToDownload.albumName,
|
||||||
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate,
|
albumArtist: trackToDownload.albumArtist,
|
||||||
quality: actualQuality,
|
coverUrl: trackToDownload.coverUrl,
|
||||||
bitDepth: backendBitDepth,
|
filePath: filePath,
|
||||||
sampleRate: backendSampleRate,
|
service: result['service'] as String? ?? item.service,
|
||||||
),
|
downloadedAt: DateTime.now(),
|
||||||
);
|
isrc: (backendISRC != null && backendISRC.isNotEmpty)
|
||||||
|
? backendISRC
|
||||||
|
: trackToDownload.isrc,
|
||||||
|
spotifyId: trackToDownload.id,
|
||||||
|
trackNumber: (backendTrackNum != null && backendTrackNum > 0)
|
||||||
|
? backendTrackNum
|
||||||
|
: trackToDownload.trackNumber,
|
||||||
|
discNumber: (backendDiscNum != null && backendDiscNum > 0)
|
||||||
|
? backendDiscNum
|
||||||
|
: trackToDownload.discNumber,
|
||||||
|
duration: trackToDownload.duration,
|
||||||
|
releaseDate: (backendYear != null && backendYear.isNotEmpty)
|
||||||
|
? backendYear
|
||||||
|
: trackToDownload.releaseDate,
|
||||||
|
quality: actualQuality,
|
||||||
|
bitDepth: backendBitDepth,
|
||||||
|
sampleRate: backendSampleRate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-remove completed item from queue (it's now in history)
|
// Auto-remove completed item from queue (it's now in history)
|
||||||
removeItem(item.id);
|
removeItem(item.id);
|
||||||
@@ -1436,7 +1603,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Increment download counter and cleanup connections periodically
|
// Increment download counter and cleanup connections periodically
|
||||||
_downloadCount++;
|
_downloadCount++;
|
||||||
if (_downloadCount % _cleanupInterval == 0) {
|
if (_downloadCount % _cleanupInterval == 0) {
|
||||||
_log.d('Cleaning up idle connections (after $_downloadCount downloads)...');
|
_log.d(
|
||||||
|
'Cleaning up idle connections (after $_downloadCount downloads)...',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await PlatformBridge.cleanupConnections();
|
await PlatformBridge.cleanupConnections();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1452,8 +1621,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
// Check for specific Deezer fallback error
|
// Check for specific Deezer fallback error
|
||||||
if (errorMsg.contains('could not find Deezer equivalent') ||
|
if (errorMsg.contains('could not find Deezer equivalent') ||
|
||||||
errorMsg.contains('track not found on Deezer')) {
|
errorMsg.contains('track not found on Deezer')) {
|
||||||
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
||||||
errorType = DownloadErrorType.notFound;
|
errorType = DownloadErrorType.notFound;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
@@ -1467,6 +1636,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final downloadQueueProvider = NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
|
final downloadQueueProvider =
|
||||||
DownloadQueueNotifier.new,
|
NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
|
||||||
);
|
DownloadQueueNotifier.new,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotiflac_android/models/track.dart';
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
final _log = AppLogger('TrackProvider');
|
||||||
|
|
||||||
class TrackState {
|
class TrackState {
|
||||||
final List<Track> tracks;
|
final List<Track> tracks;
|
||||||
@@ -210,54 +213,60 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
// Use Deezer or Spotify based on settings
|
// Use Deezer or Spotify based on settings
|
||||||
final source = metadataSource ?? 'deezer';
|
final source = metadataSource ?? 'deezer';
|
||||||
|
|
||||||
// Debug log to show which source is being used
|
_log.i('Search started: source=$source, query="$query"');
|
||||||
// ignore: avoid_print
|
|
||||||
print('[Search] Using metadata source: $source for query: "$query"');
|
|
||||||
|
|
||||||
Map<String, dynamic> results;
|
Map<String, dynamic> results;
|
||||||
if (source == 'deezer') {
|
if (source == 'deezer') {
|
||||||
|
_log.d('Calling Deezer search API...');
|
||||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
// ignore: avoid_print
|
_log.i('Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
|
||||||
} else {
|
} else {
|
||||||
|
_log.d('Calling Spotify search API...');
|
||||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||||
// ignore: avoid_print
|
_log.i('Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists');
|
||||||
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) {
|
||||||
|
_log.w('Search request cancelled (requestId=$requestId)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||||
final artistList = results['artists'] as List<dynamic>? ?? [];
|
final artistList = results['artists'] as List<dynamic>? ?? [];
|
||||||
|
|
||||||
|
_log.d('Raw results: ${trackList.length} tracks, ${artistList.length} artists');
|
||||||
|
|
||||||
// Parse tracks with error handling per item
|
// Parse tracks with error handling per item
|
||||||
final tracks = <Track>[];
|
final tracks = <Track>[];
|
||||||
for (final t in trackList) {
|
for (int i = 0; i < trackList.length; i++) {
|
||||||
|
final t = trackList[i];
|
||||||
try {
|
try {
|
||||||
if (t is Map<String, dynamic>) {
|
if (t is Map<String, dynamic>) {
|
||||||
tracks.add(_parseSearchTrack(t));
|
tracks.add(_parseSearchTrack(t));
|
||||||
|
} else {
|
||||||
|
_log.w('Track[$i] is not a Map: ${t.runtimeType}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore: avoid_print
|
_log.e('Failed to parse track[$i]: $e', e);
|
||||||
print('[Search] Failed to parse track: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse artists with error handling per item
|
// Parse artists with error handling per item
|
||||||
final artists = <SearchArtist>[];
|
final artists = <SearchArtist>[];
|
||||||
for (final a in artistList) {
|
for (int i = 0; i < artistList.length; i++) {
|
||||||
|
final a = artistList[i];
|
||||||
try {
|
try {
|
||||||
if (a is Map<String, dynamic>) {
|
if (a is Map<String, dynamic>) {
|
||||||
artists.add(_parseSearchArtist(a));
|
artists.add(_parseSearchArtist(a));
|
||||||
|
} else {
|
||||||
|
_log.w('Artist[$i] is not a Map: ${a.runtimeType}');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore: avoid_print
|
_log.e('Failed to parse artist[$i]: $e', e);
|
||||||
print('[Search] Failed to parse artist: $e');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ignore: avoid_print
|
_log.i('Search complete: ${tracks.length} tracks, ${artists.length} artists parsed successfully');
|
||||||
print('[Search] Parsed ${tracks.length} tracks, ${artists.length} artists');
|
|
||||||
|
|
||||||
state = TrackState(
|
state = TrackState(
|
||||||
tracks: tracks,
|
tracks: tracks,
|
||||||
@@ -265,9 +274,9 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasSearchText: state.hasSearchText,
|
hasSearchText: state.hasSearchText,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e, stackTrace) {
|
||||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
if (!_isRequestValid(requestId)) return;
|
||||||
// Preserve hasSearchText on error so user stays on search screen
|
_log.e('Search failed: $e', e, stackTrace);
|
||||||
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+145
-16
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||||||
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/album_screen.dart';
|
import 'package:spotiflac_android/screens/album_screen.dart';
|
||||||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||||
|
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||||
import 'package:spotiflac_android/models/download_item.dart';
|
import 'package:spotiflac_android/models/download_item.dart';
|
||||||
|
|
||||||
@@ -266,6 +267,104 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _importCsv(BuildContext context, WidgetRef ref) async {
|
||||||
|
// Show loading dialog with progress
|
||||||
|
int currentProgress = 0;
|
||||||
|
int totalTracks = 0;
|
||||||
|
|
||||||
|
// Use StatefulBuilder to update dialog content
|
||||||
|
final dialogContext = context;
|
||||||
|
bool dialogShown = false;
|
||||||
|
StateSetter? setDialogState;
|
||||||
|
|
||||||
|
void showProgressDialog() {
|
||||||
|
if (dialogShown) return;
|
||||||
|
dialogShown = true;
|
||||||
|
showDialog(
|
||||||
|
context: dialogContext,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
setDialogState = setState;
|
||||||
|
return AlertDialog(
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
totalTracks > 0
|
||||||
|
? 'Fetching metadata... $currentProgress/$totalTracks'
|
||||||
|
: 'Reading CSV...',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final tracks = await CsvImportService.pickAndParseCsv(
|
||||||
|
onProgress: (current, total) {
|
||||||
|
currentProgress = current;
|
||||||
|
totalTracks = total;
|
||||||
|
if (!dialogShown && total > 0) {
|
||||||
|
showProgressDialog();
|
||||||
|
}
|
||||||
|
setDialogState?.call(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close progress dialog
|
||||||
|
if (dialogShown && mounted) {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.isNotEmpty) {
|
||||||
|
final settings = ref.read(settingsProvider);
|
||||||
|
|
||||||
|
// Optionally show confirmation dialog
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Import Playlist'),
|
||||||
|
content: Text('Found ${tracks.length} tracks in CSV. Add them to download queue?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Import'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Added ${tracks.length} tracks to queue'),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: 'View Queue',
|
||||||
|
onPressed: () {
|
||||||
|
// Navigate to queue tab (handled by main_shell index)
|
||||||
|
// We don't have direct access to set index here easily without provider
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only show error if pick was not cancelled (handled inside service logging usually, but maybe show snackbar if file empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@@ -289,6 +388,7 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
final hasResults = _isTyping || tracks.isNotEmpty || (searchArtists != null && searchArtists.isNotEmpty) || isLoading;
|
||||||
final screenHeight = MediaQuery.of(context).size.height;
|
final screenHeight = MediaQuery.of(context).size.height;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -297,24 +397,32 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
slivers: [
|
slivers: [
|
||||||
// App Bar - always present
|
// App Bar - always present
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 130,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: LayoutBuilder(
|
||||||
expandedTitleScale: 1.3,
|
builder: (context, constraints) {
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
final maxHeight = 120 + topPadding;
|
||||||
title: Text(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
'Home',
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
return FlexibleSpaceBar(
|
||||||
fontWeight: FontWeight.bold,
|
expandedTitleScale: 1.0,
|
||||||
color: colorScheme.onSurface,
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
),
|
'Home',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -329,12 +437,27 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
children: [
|
children: [
|
||||||
SizedBox(height: screenHeight * 0.06),
|
SizedBox(height: screenHeight * 0.06),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(24),
|
width: 96,
|
||||||
|
height: 96,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
color: colorScheme.primary,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(Icons.music_note, size: 48, color: colorScheme.primary),
|
child: Image.asset(
|
||||||
|
'assets/images/logo-transparant.png',
|
||||||
|
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (_, _, _) => ClipRRect(
|
||||||
|
// Fallback to original logo if transparent one is missing
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/logo.png',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
@@ -746,12 +869,18 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
onPressed: _clearAndRefresh,
|
onPressed: _clearAndRefresh,
|
||||||
tooltip: 'Clear',
|
tooltip: 'Clear',
|
||||||
)
|
)
|
||||||
else
|
else ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.file_upload_outlined),
|
||||||
|
onPressed: () => _importCsv(context, ref),
|
||||||
|
tooltip: 'Import CSV',
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.paste),
|
icon: const Icon(Icons.paste),
|
||||||
onPressed: _pasteFromClipboard,
|
onPressed: _pasteFromClipboard,
|
||||||
tooltip: 'Paste',
|
tooltip: 'Paste',
|
||||||
),
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ 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);
|
||||||
|
|
||||||
// Navigate to Home tab
|
// Navigate to Home tab
|
||||||
if (_currentIndex != 0) {
|
if (_currentIndex != 0) {
|
||||||
_onNavTap(0);
|
_onNavTap(0);
|
||||||
|
|||||||
+21
-12
@@ -99,29 +99,38 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||||||
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
final historyItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||||
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
final historyViewMode = ref.watch(settingsProvider.select((s) => s.historyViewMode));
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar - Simplified for performance
|
// Collapsing App Bar - Simplified for performance
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 130,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: LayoutBuilder(
|
||||||
expandedTitleScale: 1.3,
|
builder: (context, constraints) {
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
final maxHeight = 120 + topPadding;
|
||||||
title: Text(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
'History',
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
return FlexibleSpaceBar(
|
||||||
fontWeight: FontWeight.bold,
|
expandedTitleScale: 1.0,
|
||||||
color: colorScheme.onSurface,
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
),
|
'History',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -109,14 +109,14 @@ class AboutPage extends StatelessWidget {
|
|||||||
githubUsername: 'sachinsenal0x64',
|
githubUsername: 'sachinsenal0x64',
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.cloud_outlined,
|
icon: Icons.cloud_outlined,
|
||||||
title: 'DoubleDouble',
|
title: 'DoubleDouble',
|
||||||
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
subtitle: 'Amazing API for Amazon Music downloads. Thank you for making it free!',
|
||||||
onTap: () => _launchUrl('https://doubledouble.top'),
|
onTap: () => _launchUrl('https://doubledouble.top'),
|
||||||
showDivider: true,
|
showDivider: true,
|
||||||
),
|
),
|
||||||
SettingsItem(
|
_AboutSettingsItem(
|
||||||
icon: Icons.music_note_outlined,
|
icon: Icons.music_note_outlined,
|
||||||
title: 'DAB Music',
|
title: 'DAB Music',
|
||||||
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
subtitle: 'The best Qobuz streaming API. Hi-Res downloads wouldn\'t be possible without this!',
|
||||||
@@ -249,30 +249,26 @@ class _AppHeaderCard extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
// App logo
|
||||||
// App logo
|
// App logo
|
||||||
Container(
|
Container(
|
||||||
width: 88,
|
width: 88,
|
||||||
height: 88,
|
height: 88,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorScheme.primaryContainer,
|
color: colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(24),
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: Image.asset(
|
||||||
borderRadius: BorderRadius.circular(24),
|
'assets/images/logo-transparant.png',
|
||||||
child: Image.asset(
|
color: colorScheme.onPrimary, // Tint with onPrimary color
|
||||||
'assets/images/logo.png',
|
fit: BoxFit.contain,
|
||||||
fit: BoxFit.cover,
|
errorBuilder: (_, _, _) => ClipRRect(
|
||||||
errorBuilder: (_, _, _) => Icon(
|
borderRadius: BorderRadius.circular(24),
|
||||||
Icons.music_note,
|
child: Image.asset(
|
||||||
size: 48,
|
'assets/images/logo.png',
|
||||||
color: colorScheme.onPrimaryContainer,
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
fit: BoxFit.cover,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -417,3 +413,80 @@ class _ContributorItem extends StatelessWidget {
|
|||||||
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings item with 40x40 icon area to align with contributor avatars
|
||||||
|
class _AboutSettingsItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String? subtitle;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final bool showDivider;
|
||||||
|
|
||||||
|
const _AboutSettingsItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
this.subtitle,
|
||||||
|
this.onTap,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
splashColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
highlightColor: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Icon with 40x40 size to match avatar
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onTap != null)
|
||||||
|
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showDivider)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
indent: 76, // 20 + 40 + 16 = 76 (same as contributor item)
|
||||||
|
endIndent: 20,
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,68 +27,108 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
leading: IconButton(
|
||||||
flexibleSpace: _AppBarTitle(title: 'Appearance', topPadding: topPadding),
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
flexibleSpace: _AppBarTitle(
|
||||||
|
title: 'Appearance',
|
||||||
|
topPadding: topPadding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Preview Section
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: _ThemePreviewCard(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Color section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Color'),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Theme section
|
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Theme')),
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_ThemeModeSelector(
|
|
||||||
currentMode: themeSettings.themeMode,
|
|
||||||
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
|
|
||||||
),
|
|
||||||
SettingsSwitchItem(
|
SettingsSwitchItem(
|
||||||
icon: Icons.brightness_2,
|
icon: Icons.wallpaper,
|
||||||
title: 'AMOLED Dark',
|
title: 'Dynamic Color',
|
||||||
subtitle: 'Pure black background for OLED screens',
|
subtitle: 'Use colors from your wallpaper',
|
||||||
value: themeSettings.useAmoled,
|
value: themeSettings.useDynamicColor,
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
|
onChanged: (value) => ref
|
||||||
|
.read(themeProvider.notifier)
|
||||||
|
.setUseDynamicColor(value),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!themeSettings.useDynamicColor)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: _ColorPalettePicker(
|
||||||
|
currentColor: themeSettings.seedColorValue,
|
||||||
|
onColorSelected: (color) =>
|
||||||
|
ref.read(themeProvider.notifier).setSeedColor(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Color section
|
// Theme section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Color')),
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Theme'),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingsSwitchItem(
|
_ThemeModeSelector(
|
||||||
icon: Icons.auto_awesome,
|
currentMode: themeSettings.themeMode,
|
||||||
title: 'Dynamic Color',
|
onChanged: (mode) =>
|
||||||
subtitle: 'Use colors from your wallpaper',
|
ref.read(themeProvider.notifier).setThemeMode(mode),
|
||||||
value: themeSettings.useDynamicColor,
|
|
||||||
onChanged: (value) => ref.read(themeProvider.notifier).setUseDynamicColor(value),
|
|
||||||
showDivider: !themeSettings.useDynamicColor,
|
|
||||||
),
|
),
|
||||||
if (!themeSettings.useDynamicColor)
|
if (Theme.of(context).brightness == Brightness.dark)
|
||||||
_ColorPicker(
|
SettingsSwitchItem(
|
||||||
currentColor: themeSettings.seedColorValue,
|
icon: Icons.brightness_2,
|
||||||
onColorSelected: (color) => ref.read(themeProvider.notifier).setSeedColor(color),
|
title: 'AMOLED Dark',
|
||||||
|
subtitle: 'Pure black background',
|
||||||
|
value: themeSettings.useAmoled,
|
||||||
|
onChanged: (value) =>
|
||||||
|
ref.read(themeProvider.notifier).setUseAmoled(value),
|
||||||
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Layout section
|
// Layout section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Layout')),
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'Layout'),
|
||||||
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SettingsGroup(
|
child: SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_HistoryViewSelector(
|
_HistoryViewSelector(
|
||||||
currentMode: settings.historyViewMode,
|
currentMode: settings.historyViewMode,
|
||||||
onChanged: (mode) => ref.read(settingsProvider.notifier).setHistoryViewMode(mode),
|
onChanged: (mode) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setHistoryViewMode(mode),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Fill remaining for scroll
|
// Fill remaining for scroll
|
||||||
const SliverFillRemaining(hasScrollBody: false, child: SizedBox()),
|
const SliverFillRemaining(
|
||||||
|
hasScrollBody: false,
|
||||||
|
child: SizedBox(height: 32),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -96,6 +136,270 @@ class AppearanceSettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A simplified preview of how the app looks with current settings
|
||||||
|
class _ThemePreviewCard extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: Container(
|
||||||
|
height: 200,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme
|
||||||
|
.surfaceContainerHighest, // Background similar to reference
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Decorative background blobs
|
||||||
|
Positioned(
|
||||||
|
top: -50,
|
||||||
|
right: -50,
|
||||||
|
child: Container(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: -30,
|
||||||
|
left: -30,
|
||||||
|
child: Container(
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Foreground "fake UI"
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 260,
|
||||||
|
height: 140,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 12, // Reduced from 20 for performance
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Fake Album Art
|
||||||
|
Container(
|
||||||
|
width: 108,
|
||||||
|
height: 108,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
color: colorScheme.onPrimary,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// Fake Text Info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.skip_previous,
|
||||||
|
size: 24,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(
|
||||||
|
Icons.play_circle_fill,
|
||||||
|
size: 32,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Icon(
|
||||||
|
Icons.skip_next,
|
||||||
|
size: 24,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Label badge
|
||||||
|
Positioned(
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.6),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
isDark ? 'Dark Mode' : 'Light Mode',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ColorPalettePicker extends StatelessWidget {
|
||||||
|
final int currentColor;
|
||||||
|
final ValueChanged<Color> onColorSelected;
|
||||||
|
const _ColorPalettePicker({
|
||||||
|
required this.currentColor,
|
||||||
|
required this.onColorSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
static const _colors = [
|
||||||
|
Color(0xFF1DB954),
|
||||||
|
Color(0xFF6750A4),
|
||||||
|
Color(0xFF0061A4),
|
||||||
|
Color(0xFF006E1C),
|
||||||
|
Color(0xFFBA1A1A),
|
||||||
|
Color(0xFF984061),
|
||||||
|
Color(0xFF7D5260),
|
||||||
|
Color(0xFF006874),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: _colors.map((color) {
|
||||||
|
final isSelected = color.toARGB32() == currentColor;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => onColorSelected(color),
|
||||||
|
child: _ColorPaletteItem(color: color, isSelected: isSelected),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ColorPaletteItem extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final bool isSelected;
|
||||||
|
|
||||||
|
const _ColorPaletteItem({required this.color, required this.isSelected});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: color,
|
||||||
|
brightness: Theme.of(context).brightness,
|
||||||
|
);
|
||||||
|
final size = 64.0;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Container(color: scheme.primaryContainer)),
|
||||||
|
Expanded(child: Container(color: scheme.tertiaryContainer)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(color: scheme.secondaryContainer),
|
||||||
|
),
|
||||||
|
Expanded(child: Container(color: scheme.surfaceContainer)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isSelected)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(Icons.check, size: 16, color: scheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Optimized app bar title with animation
|
/// Optimized app bar title with animation
|
||||||
class _AppBarTitle extends StatelessWidget {
|
class _AppBarTitle extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
@@ -110,7 +414,9 @@ class _AppBarTitle extends StatelessWidget {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final expandRatio =
|
||||||
|
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
@@ -132,19 +438,39 @@ class _AppBarTitle extends StatelessWidget {
|
|||||||
class _ThemeModeSelector extends StatelessWidget {
|
class _ThemeModeSelector extends StatelessWidget {
|
||||||
final ThemeMode currentMode;
|
final ThemeMode currentMode;
|
||||||
final ValueChanged<ThemeMode> onChanged;
|
final ValueChanged<ThemeMode> onChanged;
|
||||||
const _ThemeModeSelector({required this.currentMode, required this.onChanged});
|
const _ThemeModeSelector({
|
||||||
|
required this.currentMode,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_ThemeModeChip(
|
||||||
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)),
|
icon: Icons.brightness_auto,
|
||||||
const SizedBox(width: 8),
|
label: 'System',
|
||||||
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)),
|
isSelected: currentMode == ThemeMode.system,
|
||||||
]),
|
onTap: () => onChanged(ThemeMode.system),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ThemeModeChip(
|
||||||
|
icon: Icons.light_mode,
|
||||||
|
label: 'Light',
|
||||||
|
isSelected: currentMode == ThemeMode.light,
|
||||||
|
onTap: () => onChanged(ThemeMode.light),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ThemeModeChip(
|
||||||
|
icon: Icons.dark_mode,
|
||||||
|
label: 'Dark',
|
||||||
|
isSelected: currentMode == ThemeMode.dark,
|
||||||
|
onTap: () => onChanged(ThemeMode.dark),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +480,12 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ThemeModeChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -165,8 +496,14 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest
|
||||||
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -174,7 +511,10 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: !isDark && !isSelected
|
border: !isDark && !isSelected
|
||||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
? Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -185,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -200,49 +556,13 @@ class _ThemeModeChip extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ColorPicker extends StatelessWidget {
|
|
||||||
final int currentColor;
|
|
||||||
final ValueChanged<Color> onColorSelected;
|
|
||||||
const _ColorPicker({required this.currentColor, required this.onColorSelected});
|
|
||||||
|
|
||||||
static const _colors = [
|
|
||||||
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
|
|
||||||
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
|
|
||||||
];
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
|
|
||||||
final isSelected = color.toARGB32() == currentColor;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () => onColorSelected(color),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
width: 44, height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color, shape: BoxShape.circle,
|
|
||||||
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
|
|
||||||
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
|
|
||||||
),
|
|
||||||
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList()),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HistoryViewSelector extends StatelessWidget {
|
class _HistoryViewSelector extends StatelessWidget {
|
||||||
final String currentMode;
|
final String currentMode;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _HistoryViewSelector({required this.currentMode, required this.onChanged});
|
const _HistoryViewSelector({
|
||||||
|
required this.currentMode,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -254,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
padding: const EdgeInsets.only(left: 8, bottom: 8),
|
||||||
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Text(
|
||||||
|
'History View',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_ViewModeChip(
|
||||||
|
icon: Icons.view_list,
|
||||||
|
label: 'List',
|
||||||
|
isSelected: currentMode == 'list',
|
||||||
|
onTap: () => onChanged('list'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ViewModeChip(
|
||||||
|
icon: Icons.grid_view,
|
||||||
|
label: 'Grid',
|
||||||
|
isSelected: currentMode == 'grid',
|
||||||
|
onTap: () => onChanged('grid'),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Row(children: [
|
|
||||||
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
|
|
||||||
]),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -272,7 +609,12 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ViewModeChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -281,8 +623,14 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
|
|
||||||
// Unselected chips need contrast with card background
|
// Unselected chips need contrast with card background
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest);
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
|
: Color.alphaBlend(
|
||||||
|
Colors.black.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -290,7 +638,10 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: !isDark && !isSelected
|
border: !isDark && !isSelected
|
||||||
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1)
|
? Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -301,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,16 +28,25 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)),
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
flexibleSpace: LayoutBuilder(
|
flexibleSpace: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final maxHeight = 120 + topPadding;
|
final maxHeight = 120 + topPadding;
|
||||||
final minHeight = kToolbarHeight + topPadding;
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
final expandRatio =
|
||||||
|
((constraints.maxHeight - minHeight) /
|
||||||
|
(maxHeight - minHeight))
|
||||||
|
.clamp(0.0, 1.0);
|
||||||
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
|
||||||
return FlexibleSpaceBar(
|
return FlexibleSpaceBar(
|
||||||
expandedTitleScale: 1.0,
|
expandedTitleScale: 1.0,
|
||||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
titlePadding: EdgeInsets.only(
|
||||||
|
left: leftPadding,
|
||||||
|
bottom: 16,
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'Download',
|
'Download',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -51,89 +60,117 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Service section
|
// Service section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Service')),
|
const SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: 'Service'),
|
||||||
child: SettingsGroup(
|
),
|
||||||
children: [
|
SliverToBoxAdapter(
|
||||||
_ServiceSelector(
|
child: SettingsGroup(
|
||||||
currentService: settings.defaultService,
|
children: [
|
||||||
onChanged: (service) => ref.read(settingsProvider.notifier).setDefaultService(service),
|
_ServiceSelector(
|
||||||
),
|
currentService: settings.defaultService,
|
||||||
],
|
onChanged: (service) => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDefaultService(service),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Quality section
|
// Quality section
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Audio Quality')),
|
const SliverToBoxAdapter(
|
||||||
SliverToBoxAdapter(
|
child: SettingsSectionHeader(title: 'Audio Quality'),
|
||||||
child: SettingsGroup(
|
),
|
||||||
children: [
|
SliverToBoxAdapter(
|
||||||
SettingsSwitchItem(
|
child: SettingsGroup(
|
||||||
icon: Icons.tune,
|
children: [
|
||||||
title: 'Ask Before Download',
|
SettingsSwitchItem(
|
||||||
subtitle: 'Choose quality for each download',
|
icon: Icons.tune,
|
||||||
value: settings.askQualityBeforeDownload,
|
title: 'Ask Before Download',
|
||||||
onChanged: (value) => ref.read(settingsProvider.notifier).setAskQualityBeforeDownload(value),
|
subtitle: 'Choose quality for each download',
|
||||||
),
|
value: settings.askQualityBeforeDownload,
|
||||||
if (!settings.askQualityBeforeDownload) ...[
|
onChanged: (value) => ref
|
||||||
_QualityOption(
|
.read(settingsProvider.notifier)
|
||||||
title: 'FLAC Lossless',
|
.setAskQualityBeforeDownload(value),
|
||||||
subtitle: '16-bit / 44.1kHz',
|
|
||||||
isSelected: settings.audioQuality == 'LOSSLESS',
|
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('LOSSLESS'),
|
|
||||||
),
|
),
|
||||||
_QualityOption(
|
if (!settings.askQualityBeforeDownload) ...[
|
||||||
title: 'Hi-Res FLAC',
|
_QualityOption(
|
||||||
subtitle: '24-bit / up to 96kHz',
|
title: 'FLAC Lossless',
|
||||||
isSelected: settings.audioQuality == 'HI_RES',
|
subtitle: '16-bit / 44.1kHz',
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES'),
|
isSelected: settings.audioQuality == 'LOSSLESS',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('LOSSLESS'),
|
||||||
|
),
|
||||||
|
_QualityOption(
|
||||||
|
title: 'Hi-Res FLAC',
|
||||||
|
subtitle: '24-bit / up to 96kHz',
|
||||||
|
isSelected: settings.audioQuality == 'HI_RES',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('HI_RES'),
|
||||||
|
),
|
||||||
|
_QualityOption(
|
||||||
|
title: 'Hi-Res FLAC Max',
|
||||||
|
subtitle: '24-bit / up to 192kHz',
|
||||||
|
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
||||||
|
onTap: () => ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setAudioQuality('HI_RES_LOSSLESS'),
|
||||||
|
showDivider: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// File settings section
|
||||||
|
const SliverToBoxAdapter(
|
||||||
|
child: SettingsSectionHeader(title: 'File Settings'),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SettingsGroup(
|
||||||
|
children: [
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.text_fields,
|
||||||
|
title: 'Filename Format',
|
||||||
|
subtitle: settings.filenameFormat,
|
||||||
|
onTap: () => _showFormatEditor(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.filenameFormat,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_QualityOption(
|
SettingsItem(
|
||||||
title: 'Hi-Res FLAC Max',
|
icon: Icons.folder_outlined,
|
||||||
subtitle: '24-bit / up to 192kHz',
|
title: 'Download Directory',
|
||||||
isSelected: settings.audioQuality == 'HI_RES_LOSSLESS',
|
subtitle: settings.downloadDirectory.isEmpty
|
||||||
onTap: () => ref.read(settingsProvider.notifier).setAudioQuality('HI_RES_LOSSLESS'),
|
? (Platform.isIOS
|
||||||
|
? 'App Documents Folder'
|
||||||
|
: 'Music/SpotiFLAC')
|
||||||
|
: settings.downloadDirectory,
|
||||||
|
onTap: () => _pickDirectory(context, ref),
|
||||||
|
),
|
||||||
|
SettingsItem(
|
||||||
|
icon: Icons.create_new_folder_outlined,
|
||||||
|
title: 'Folder Organization',
|
||||||
|
subtitle: _getFolderOrganizationLabel(
|
||||||
|
settings.folderOrganization,
|
||||||
|
),
|
||||||
|
onTap: () => _showFolderOrganizationPicker(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings.folderOrganization,
|
||||||
|
),
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// File settings section
|
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'File Settings')),
|
],
|
||||||
SliverToBoxAdapter(
|
),
|
||||||
child: SettingsGroup(
|
|
||||||
children: [
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.text_fields,
|
|
||||||
title: 'Filename Format',
|
|
||||||
subtitle: settings.filenameFormat,
|
|
||||||
onTap: () => _showFormatEditor(context, ref, settings.filenameFormat),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.folder_outlined,
|
|
||||||
title: 'Download Directory',
|
|
||||||
subtitle: settings.downloadDirectory.isEmpty
|
|
||||||
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
|
|
||||||
: settings.downloadDirectory,
|
|
||||||
onTap: () => _pickDirectory(context, ref),
|
|
||||||
),
|
|
||||||
SettingsItem(
|
|
||||||
icon: Icons.create_new_folder_outlined,
|
|
||||||
title: 'Folder Organization',
|
|
||||||
subtitle: _getFolderOrganizationLabel(settings.folderOrganization),
|
|
||||||
onTap: () => _showFolderOrganizationPicker(context, ref, settings.folderOrganization),
|
|
||||||
showDivider: false,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,26 +178,176 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
void _showFormatEditor(BuildContext context, WidgetRef ref, String current) {
|
||||||
final controller = TextEditingController(text: current);
|
final controller = TextEditingController(text: current);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
final tags = [
|
||||||
|
'{artist}',
|
||||||
|
'{title}',
|
||||||
|
'{album}',
|
||||||
|
'{track}',
|
||||||
|
'{year}',
|
||||||
|
'{disc}',
|
||||||
|
];
|
||||||
|
|
||||||
|
void insertTag(String tag) {
|
||||||
|
final text = controller.text;
|
||||||
|
final selection = controller.selection;
|
||||||
|
final start = selection.start >= 0 ? selection.start : text.length;
|
||||||
|
final end = selection.end >= 0 ? selection.end : text.length;
|
||||||
|
|
||||||
|
String insertion = tag;
|
||||||
|
if (start > 0) {
|
||||||
|
final before = text.substring(0, start);
|
||||||
|
// Smart separator: if not starting a file and no hyphen separator exists, add " - "
|
||||||
|
if (!before.trim().endsWith('-')) {
|
||||||
|
insertion = ' - $tag';
|
||||||
|
} else if (before.trim().endsWith('-') && !before.endsWith(' ')) {
|
||||||
|
// If ends with '-' but no space, add space
|
||||||
|
insertion = ' $tag';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newText = text.replaceRange(start, end, insertion);
|
||||||
|
controller.value = TextEditingValue(
|
||||||
|
text: newText,
|
||||||
|
selection: TextSelection.collapsed(offset: start + insertion.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context, isScrollControlled: true,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
backgroundColor: colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (context) => Padding(
|
builder: (context) => Padding(
|
||||||
padding: EdgeInsets.fromLTRB(24, 24, 24, MediaQuery.of(context).viewInsets.bottom + 24),
|
padding: EdgeInsets.only(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
Text('Filename Format', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
),
|
||||||
const SizedBox(height: 16),
|
child: SingleChildScrollView(
|
||||||
TextField(controller: controller, decoration: const InputDecoration(hintText: '{artist} - {title}'), autofocus: true),
|
child: SafeArea(
|
||||||
const SizedBox(height: 16),
|
child: Padding(
|
||||||
Text('Available: {title}, {artist}, {album}, {track}, {year}, {disc}',
|
padding: const EdgeInsets.all(24),
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Column(
|
||||||
const SizedBox(height: 24),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')),
|
children: [
|
||||||
const SizedBox(width: 8),
|
Center(
|
||||||
FilledButton(onPressed: () { ref.read(settingsProvider.notifier).setFilenameFormat(controller.text); Navigator.pop(context); }, child: const Text('Save')),
|
child: Container(
|
||||||
]),
|
width: 32,
|
||||||
]),
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.outlineVariant,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Filename Format',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Customize how your files are named.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '{artist} - {title}',
|
||||||
|
filled: true,
|
||||||
|
fillColor: colorScheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
Text(
|
||||||
|
'Tap to insert tag:',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: tags.map((tag) {
|
||||||
|
return ActionChip(
|
||||||
|
label: Text(tag),
|
||||||
|
onPressed: () => insertTag(tag),
|
||||||
|
backgroundColor: colorScheme.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.5),
|
||||||
|
side: BorderSide.none,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFilenameFormat(controller.text);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Save Format'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -172,7 +359,8 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
} else {
|
} else {
|
||||||
// Android: Use file picker
|
// Android: Use file picker
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
if (result != null)
|
||||||
|
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +369,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (ctx) => SafeArea(
|
builder: (ctx) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -189,13 +379,20 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
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(
|
||||||
|
'Download Location',
|
||||||
|
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.',
|
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
@@ -205,7 +402,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
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();
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path);
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDownloadDirectory(dir.path);
|
||||||
if (ctx.mounted) Navigator.pop(ctx);
|
if (ctx.mounted) Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -218,7 +417,9 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
// Note: iOS requires folder to have at least one file to be selectable
|
// Note: iOS requires folder to have at least one file to be selectable
|
||||||
final result = await FilePicker.platform.getDirectoryPath();
|
final result = await FilePicker.platform.getDirectoryPath();
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setDownloadDirectory(result);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -232,12 +433,18 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 20,
|
||||||
|
color: colorScheme.tertiary,
|
||||||
|
),
|
||||||
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.',
|
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -264,12 +471,18 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showFolderOrganizationPicker(BuildContext context, WidgetRef ref, String current) {
|
void _showFolderOrganizationPicker(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String current,
|
||||||
|
) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: colorScheme.surfaceContainerHigh,
|
backgroundColor: colorScheme.surfaceContainerHigh,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||||
|
),
|
||||||
builder: (context) => SafeArea(
|
builder: (context) => SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -277,39 +490,69 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
|
||||||
child: Text('Folder Organization', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
|
child: Text(
|
||||||
|
'Folder Organization',
|
||||||
|
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('Organize downloaded files into folders', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
child: Text(
|
||||||
|
'Organize downloaded files into folders',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'None',
|
title: 'None',
|
||||||
subtitle: 'All files in download folder',
|
subtitle: 'All files in download folder',
|
||||||
example: 'SpotiFLAC/Track.flac',
|
example: 'SpotiFLAC/Track.flac',
|
||||||
isSelected: current == 'none',
|
isSelected: current == 'none',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('none'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFolderOrganization('none');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist',
|
title: 'By Artist',
|
||||||
subtitle: 'Separate folder for each artist',
|
subtitle: 'Separate folder for each artist',
|
||||||
example: 'SpotiFLAC/Artist Name/Track.flac',
|
example: 'SpotiFLAC/Artist Name/Track.flac',
|
||||||
isSelected: current == 'artist',
|
isSelected: current == 'artist',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFolderOrganization('artist');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Album',
|
title: 'By Album',
|
||||||
subtitle: 'Separate folder for each album',
|
subtitle: 'Separate folder for each album',
|
||||||
example: 'SpotiFLAC/Album Name/Track.flac',
|
example: 'SpotiFLAC/Album Name/Track.flac',
|
||||||
isSelected: current == 'album',
|
isSelected: current == 'album',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('album'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFolderOrganization('album');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_FolderOption(
|
_FolderOption(
|
||||||
title: 'By Artist & Album',
|
title: 'By Artist & Album',
|
||||||
subtitle: 'Nested folders for artist and album',
|
subtitle: 'Nested folders for artist and album',
|
||||||
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
example: 'SpotiFLAC/Artist/Album/Track.flac',
|
||||||
isSelected: current == 'artist_album',
|
isSelected: current == 'artist_album',
|
||||||
onTap: () { ref.read(settingsProvider.notifier).setFolderOrganization('artist_album'); Navigator.pop(context); },
|
onTap: () {
|
||||||
|
ref
|
||||||
|
.read(settingsProvider.notifier)
|
||||||
|
.setFolderOrganization('artist_album');
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
@@ -322,19 +565,39 @@ class DownloadSettingsPage extends ConsumerWidget {
|
|||||||
class _ServiceSelector extends StatelessWidget {
|
class _ServiceSelector extends StatelessWidget {
|
||||||
final String currentService;
|
final String currentService;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String> onChanged;
|
||||||
const _ServiceSelector({required this.currentService, required this.onChanged});
|
const _ServiceSelector({
|
||||||
|
required this.currentService,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')),
|
children: [
|
||||||
const SizedBox(width: 8),
|
_ServiceChip(
|
||||||
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')),
|
icon: Icons.music_note,
|
||||||
const SizedBox(width: 8),
|
label: 'Tidal',
|
||||||
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')),
|
isSelected: currentService == 'tidal',
|
||||||
]),
|
onTap: () => onChanged('tidal'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.album,
|
||||||
|
label: 'Qobuz',
|
||||||
|
isSelected: currentService == 'qobuz',
|
||||||
|
onTap: () => onChanged('qobuz'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_ServiceChip(
|
||||||
|
icon: Icons.shopping_bag,
|
||||||
|
label: 'Amazon',
|
||||||
|
isSelected: currentService == 'amazon',
|
||||||
|
onTap: () => onChanged('amazon'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,7 +607,12 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap});
|
const _ServiceChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -352,7 +620,10 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
? Color.alphaBlend(
|
||||||
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
colorScheme.surface,
|
||||||
|
)
|
||||||
: colorScheme.surfaceContainerHigh;
|
: colorScheme.surfaceContainerHigh;
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
@@ -364,13 +635,29 @@ class _ServiceChip extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant),
|
children: [
|
||||||
const SizedBox(height: 6),
|
Icon(
|
||||||
Text(label, style: TextStyle(fontSize: 12,
|
icon,
|
||||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
color: isSelected
|
||||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)),
|
? colorScheme.onPrimaryContainer
|
||||||
]),
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: isSelected
|
||||||
|
? FontWeight.w600
|
||||||
|
: FontWeight.normal,
|
||||||
|
color: isSelected
|
||||||
|
? colorScheme.onPrimaryContainer
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -384,7 +671,13 @@ class _QualityOption extends StatelessWidget {
|
|||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final bool showDivider;
|
final bool showDivider;
|
||||||
const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true});
|
const _QualityOption({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
this.showDivider = true,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -404,7 +697,12 @@ class _QualityOption extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
Text(title, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -434,7 +732,13 @@ class _FolderOption extends StatelessWidget {
|
|||||||
final String example;
|
final String example;
|
||||||
final bool isSelected;
|
final bool isSelected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _FolderOption({required this.title, required this.subtitle, required this.example, required this.isSelected, required this.onTap});
|
const _FolderOption({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.example,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -447,10 +751,19 @@ class _FolderOption extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(subtitle),
|
Text(subtitle),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(example, style: TextStyle(fontFamily: 'monospace', fontSize: 11, color: colorScheme.primary)),
|
Text(
|
||||||
|
example,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: 11,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: isSelected ? Icon(Icons.check_circle, color: colorScheme.primary) : Icon(Icons.circle_outlined, color: colorScheme.outline),
|
trailing: isSelected
|
||||||
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -14,29 +14,38 @@ class SettingsTab extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final topPadding = MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
slivers: [
|
slivers: [
|
||||||
// Collapsing App Bar
|
// Collapsing App Bar
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
expandedHeight: 130,
|
expandedHeight: 120 + topPadding,
|
||||||
collapsedHeight: kToolbarHeight,
|
collapsedHeight: kToolbarHeight,
|
||||||
floating: false,
|
floating: false,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
backgroundColor: colorScheme.surface,
|
backgroundColor: colorScheme.surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: LayoutBuilder(
|
||||||
expandedTitleScale: 1.3,
|
builder: (context, constraints) {
|
||||||
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
final maxHeight = 120 + topPadding;
|
||||||
title: Text(
|
final minHeight = kToolbarHeight + topPadding;
|
||||||
'Settings',
|
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 28,
|
return FlexibleSpaceBar(
|
||||||
fontWeight: FontWeight.bold,
|
expandedTitleScale: 1.0,
|
||||||
color: colorScheme.onSurface,
|
titlePadding: const EdgeInsets.only(left: 24, bottom: 16),
|
||||||
),
|
title: Text(
|
||||||
),
|
'Settings',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20 + (14 * expandRatio), // 20 -> 34
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -353,19 +353,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
// Metadata grid
|
// Metadata grid
|
||||||
_buildMetadataGrid(context, colorScheme),
|
_buildMetadataGrid(context, colorScheme),
|
||||||
|
|
||||||
// Spotify link button
|
// Streaming service link button
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
OutlinedButton.icon(
|
Builder(
|
||||||
onPressed: () => _openSpotifyUrl(context),
|
builder: (context) {
|
||||||
icon: const Icon(Icons.open_in_new, size: 18),
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
label: const Text('Open in Spotify'),
|
return OutlinedButton.icon(
|
||||||
style: OutlinedButton.styleFrom(
|
onPressed: () => _openServiceUrl(context),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
icon: const Icon(Icons.open_in_new, size: 18),
|
||||||
shape: RoundedRectangleBorder(
|
label: Text(isDeezer ? 'Open in Deezer' : 'Open in Spotify'),
|
||||||
borderRadius: BorderRadius.circular(12),
|
style: OutlinedButton.styleFrom(
|
||||||
),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -374,16 +379,24 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openSpotifyUrl(BuildContext context) async {
|
Future<void> _openServiceUrl(BuildContext context) async {
|
||||||
if (item.spotifyId == null) return;
|
if (item.spotifyId == null) return;
|
||||||
|
|
||||||
final webUrl = 'https://open.spotify.com/track/${item.spotifyId}';
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
final spotifyUri = Uri.parse('spotify:track:${item.spotifyId}');
|
final rawId = item.spotifyId!.replaceAll('deezer:', '');
|
||||||
|
|
||||||
|
final webUrl = isDeezer
|
||||||
|
? 'https://www.deezer.com/track/$rawId'
|
||||||
|
: 'https://open.spotify.com/track/$rawId';
|
||||||
|
|
||||||
|
final appUri = isDeezer
|
||||||
|
? Uri.parse('deezer://www.deezer.com/track/$rawId')
|
||||||
|
: Uri.parse('spotify:track:$rawId');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to open in Spotify app first using URI scheme
|
// Try to open in App first using URI scheme
|
||||||
final launched = await launchUrl(
|
final launched = await launchUrl(
|
||||||
spotifyUri,
|
appUri,
|
||||||
mode: LaunchMode.externalApplication,
|
mode: LaunchMode.externalApplication,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -406,7 +419,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
_copyToClipboard(context, webUrl);
|
_copyToClipboard(context, webUrl);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Spotify URL copied to clipboard')),
|
SnackBar(content: Text('${isDeezer ? 'Deezer' : 'Spotify'} URL copied to clipboard')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -429,7 +442,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_MetadataItem('Album', albumName),
|
_MetadataItem('Album', albumName),
|
||||||
if (trackNumber != null && trackNumber! > 0)
|
if (trackNumber != null && trackNumber! > 0)
|
||||||
_MetadataItem('Track number', trackNumber.toString()),
|
_MetadataItem('Track number', trackNumber.toString()),
|
||||||
if (discNumber != null && discNumber! > 1)
|
if (discNumber != null && discNumber! > 0)
|
||||||
_MetadataItem('Disc number', discNumber.toString()),
|
_MetadataItem('Disc number', discNumber.toString()),
|
||||||
if (item.duration != null)
|
if (item.duration != null)
|
||||||
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
_MetadataItem('Duration', _formatDuration(item.duration!)),
|
||||||
@@ -439,11 +452,18 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||||||
_MetadataItem('Release date', releaseDate!),
|
_MetadataItem('Release date', releaseDate!),
|
||||||
if (isrc != null && isrc!.isNotEmpty)
|
if (isrc != null && isrc!.isNotEmpty)
|
||||||
_MetadataItem('ISRC', isrc!),
|
_MetadataItem('ISRC', isrc!),
|
||||||
if (item.spotifyId != null && item.spotifyId!.isNotEmpty)
|
];
|
||||||
_MetadataItem('Spotify ID', item.spotifyId!),
|
|
||||||
|
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
|
||||||
|
final isDeezer = item.spotifyId!.contains('deezer');
|
||||||
|
final cleanId = item.spotifyId!.replaceAll('deezer:', '');
|
||||||
|
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.addAll([
|
||||||
_MetadataItem('Service', item.service.toUpperCase()),
|
_MetadataItem('Service', item.service.toUpperCase()),
|
||||||
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
_MetadataItem('Downloaded', _formatFullDate(item.downloadedAt)),
|
||||||
];
|
]);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: items.map((metadata) {
|
children: items.map((metadata) {
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:spotiflac_android/models/track.dart';
|
||||||
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||||
|
import 'package:spotiflac_android/utils/logger.dart';
|
||||||
|
|
||||||
|
class CsvImportService {
|
||||||
|
static final _log = AppLogger('CsvImportService');
|
||||||
|
|
||||||
|
/// Pick and parse CSV file, then enrich metadata from Deezer
|
||||||
|
/// [onProgress] callback receives (current, total) for progress updates
|
||||||
|
static Future<List<Track>> pickAndParseCsv({
|
||||||
|
void Function(int current, int total)? onProgress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['csv'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.single.path != null) {
|
||||||
|
final file = File(result.files.single.path!);
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final tracks = _parseCsv(content);
|
||||||
|
|
||||||
|
// Enrich tracks with metadata from Deezer (cover URL, duration, etc.)
|
||||||
|
if (tracks.isNotEmpty) {
|
||||||
|
return await _enrichTracksMetadata(tracks, onProgress: onProgress);
|
||||||
|
}
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Error picking/parsing CSV: $e');
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enrich tracks with metadata from Deezer using ISRC or search
|
||||||
|
/// This fetches cover URL, duration, and other metadata that CSV doesn't have
|
||||||
|
static Future<List<Track>> _enrichTracksMetadata(
|
||||||
|
List<Track> tracks, {
|
||||||
|
void Function(int current, int total)? onProgress,
|
||||||
|
}) async {
|
||||||
|
_log.i('Enriching metadata for ${tracks.length} tracks from Deezer...');
|
||||||
|
final enrichedTracks = <Track>[];
|
||||||
|
|
||||||
|
for (int i = 0; i < tracks.length; i++) {
|
||||||
|
final track = tracks[i];
|
||||||
|
onProgress?.call(i + 1, tracks.length);
|
||||||
|
|
||||||
|
// Only enrich if missing cover/duration
|
||||||
|
if (track.coverUrl == null || track.duration == 0) {
|
||||||
|
Map<String, dynamic>? trackData;
|
||||||
|
|
||||||
|
// Try ISRC first if available
|
||||||
|
if (track.isrc != null && track.isrc!.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
trackData = await PlatformBridge.searchDeezerByISRC(track.isrc!);
|
||||||
|
_log.d('ISRC enrichment success for ${track.name}');
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('ISRC search failed for ${track.name}, trying text search...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to text search if ISRC failed or not available
|
||||||
|
if (trackData == null) {
|
||||||
|
try {
|
||||||
|
final query = '${track.artistName} ${track.name}';
|
||||||
|
final searchResult = await PlatformBridge.searchDeezerAll(query, trackLimit: 5);
|
||||||
|
|
||||||
|
if (searchResult.containsKey('tracks')) {
|
||||||
|
final tracksList = searchResult['tracks'] as List<dynamic>?;
|
||||||
|
if (tracksList != null && tracksList.isNotEmpty) {
|
||||||
|
// Find best match by comparing names
|
||||||
|
for (final result in tracksList) {
|
||||||
|
final resultMap = result as Map<String, dynamic>;
|
||||||
|
final resultName = (resultMap['name'] as String?)?.toLowerCase() ?? '';
|
||||||
|
final trackNameLower = track.name.toLowerCase();
|
||||||
|
|
||||||
|
// Check if track name matches (contains or equals)
|
||||||
|
if (resultName.contains(trackNameLower) || trackNameLower.contains(resultName)) {
|
||||||
|
trackData = resultMap;
|
||||||
|
_log.d('Text search match for ${track.name}: $resultName');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no exact match, use first result
|
||||||
|
if (trackData == null && tracksList.isNotEmpty) {
|
||||||
|
trackData = tracksList.first as Map<String, dynamic>;
|
||||||
|
_log.d('Using first search result for ${track.name}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_log.w('Text search also failed for ${track.name}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply enriched data if found
|
||||||
|
if (trackData != null) {
|
||||||
|
final coverUrl = trackData['images'] as String?;
|
||||||
|
final durationMs = trackData['duration_ms'] as int? ?? 0;
|
||||||
|
final deezerIdRaw = trackData['spotify_id'] as String?;
|
||||||
|
|
||||||
|
enrichedTracks.add(Track(
|
||||||
|
id: deezerIdRaw ?? track.id,
|
||||||
|
name: trackData['name'] as String? ?? track.name,
|
||||||
|
artistName: trackData['artists'] as String? ?? track.artistName,
|
||||||
|
albumName: trackData['album_name'] as String? ?? track.albumName,
|
||||||
|
albumArtist: trackData['album_artist'] as String?,
|
||||||
|
coverUrl: coverUrl ?? track.coverUrl,
|
||||||
|
isrc: trackData['isrc'] as String? ?? track.isrc,
|
||||||
|
duration: durationMs > 0 ? durationMs ~/ 1000 : track.duration,
|
||||||
|
trackNumber: trackData['track_number'] as int? ?? track.trackNumber,
|
||||||
|
discNumber: trackData['disc_number'] as int? ?? track.discNumber,
|
||||||
|
releaseDate: trackData['release_date'] as String? ?? track.releaseDate,
|
||||||
|
));
|
||||||
|
|
||||||
|
_log.d('Enriched: ${track.name} - cover: ${coverUrl != null}, duration: ${durationMs ~/ 1000}s');
|
||||||
|
|
||||||
|
// Small delay to avoid rate limiting
|
||||||
|
if (i < tracks.length - 1) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep original track if enrichment failed or not needed
|
||||||
|
enrichedTracks.add(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Enrichment complete: ${enrichedTracks.length} tracks');
|
||||||
|
return enrichedTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Track> _parseCsv(String content) {
|
||||||
|
final List<Track> tracks = [];
|
||||||
|
final lines = content.split(RegExp(r'\r\n|\r|\n')); // Handle various newline formats
|
||||||
|
if (lines.isEmpty) return tracks;
|
||||||
|
|
||||||
|
// Detect headers line (assume first non-empty line)
|
||||||
|
int startIdx = 0;
|
||||||
|
while (startIdx < lines.length && lines[startIdx].trim().isEmpty) {
|
||||||
|
startIdx++;
|
||||||
|
}
|
||||||
|
if (startIdx >= lines.length) return tracks;
|
||||||
|
|
||||||
|
final headers = _parseLine(lines[startIdx]);
|
||||||
|
final colMap = <String, int>{};
|
||||||
|
for (int i = 0; i < headers.length; i++) {
|
||||||
|
// Normalize header: lowercase, trim, remove quotes
|
||||||
|
String h = _cleanValue(headers[i]).toLowerCase();
|
||||||
|
colMap[h] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.d('CSV Headers: ${colMap.keys.toList()}');
|
||||||
|
|
||||||
|
// Parse rows
|
||||||
|
for (int i = startIdx + 1; i < lines.length; i++) {
|
||||||
|
final line = lines[i].trim();
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
|
||||||
|
final values = _parseLine(line);
|
||||||
|
|
||||||
|
// Helper to get value securely
|
||||||
|
String? getVal(List<String> keys) {
|
||||||
|
return _getValue(values, colMap, keys);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? trackName = getVal(['track name', 'track', 'name', 'title']);
|
||||||
|
String? artistName = getVal(['artist name', 'artist']);
|
||||||
|
String? albumName = getVal(['album name', 'album']);
|
||||||
|
String? isrc = getVal(['isrc']); // Often formatted with leading/trailing quotes
|
||||||
|
String? spotifyId = getVal(['spotify - id', 'spotify id', 'id', 'uri']); // Uri might need parsing
|
||||||
|
|
||||||
|
// If 'spotify uri' contains the id: 'spotify:track:ID'
|
||||||
|
if (spotifyId != null && spotifyId.startsWith('spotify:track:')) {
|
||||||
|
spotifyId = spotifyId.replaceAll('spotify:track:', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic validation: Need at least name and artist, OR a spotify ID
|
||||||
|
if ((trackName != null && trackName.isNotEmpty && artistName != null) || (spotifyId != null && spotifyId.isNotEmpty)) {
|
||||||
|
tracks.add(Track(
|
||||||
|
id: spotifyId ?? 'csv_${DateTime.now().millisecondsSinceEpoch}_$i',
|
||||||
|
name: trackName ?? 'Unknown Track',
|
||||||
|
artistName: artistName ?? 'Unknown Artist',
|
||||||
|
albumName: albumName ?? 'Unknown Album',
|
||||||
|
isrc: isrc,
|
||||||
|
duration: 0, // Will be updated by enrichment later
|
||||||
|
coverUrl: null, // Will be fetched by enrichment
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.i('Parsed ${tracks.length} tracks from CSV');
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _getValue(List<String> values, Map<String, int> colMap, List<String> possibleKeys) {
|
||||||
|
for (final key in possibleKeys) {
|
||||||
|
if (colMap.containsKey(key)) {
|
||||||
|
final index = colMap[key]!;
|
||||||
|
if (index < values.length) {
|
||||||
|
return _cleanValue(values[index]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _cleanValue(String val) {
|
||||||
|
val = val.trim();
|
||||||
|
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
||||||
|
val = val.substring(1, val.length - 1);
|
||||||
|
}
|
||||||
|
// Handle double quotes escape in CSV ("" -> ")
|
||||||
|
val = val.replaceAll('""', '"');
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robust CSV Line Parser
|
||||||
|
static List<String> _parseLine(String line) {
|
||||||
|
final List<String> result = [];
|
||||||
|
bool inQuote = false;
|
||||||
|
StringBuffer buffer = StringBuffer();
|
||||||
|
|
||||||
|
for (int i=0; i<line.length; i++) {
|
||||||
|
String char = line[i];
|
||||||
|
if (char == '"') {
|
||||||
|
// Look ahead to check for escaped quote
|
||||||
|
if (i + 1 < line.length && line[i+1] == '"') {
|
||||||
|
buffer.write('"'); // Keep format for now, _cleanValue handles unescaping logic differently...
|
||||||
|
// Wait, standard CSV: "Thumb ""Up""" -> Thumb "Up"
|
||||||
|
// My _cleanValue handles it, so I should just preserve raw content here mostly,
|
||||||
|
// BUT I need to know if " toggles inQuote.
|
||||||
|
// Escaped "" does NOT toggle inQuote mode effectively (it counts as literal char inside quote).
|
||||||
|
buffer.write('"'); // Write 1st quote
|
||||||
|
i++; // Skip next quote char loop
|
||||||
|
buffer.write('"'); // Write 2nd quote
|
||||||
|
} else {
|
||||||
|
inQuote = !inQuote;
|
||||||
|
buffer.write(char);
|
||||||
|
}
|
||||||
|
} else if (char == ',' && !inQuote) {
|
||||||
|
result.add(buffer.toString());
|
||||||
|
buffer.clear();
|
||||||
|
} else {
|
||||||
|
buffer.write(char);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.add(buffer.toString());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
-78
@@ -7,11 +7,9 @@ class AppTheme {
|
|||||||
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
static const Color defaultSeedColor = Color(kDefaultSeedColor);
|
||||||
|
|
||||||
/// Create light theme
|
/// Create light theme
|
||||||
static ThemeData light({
|
static ThemeData light({ColorScheme? dynamicScheme, Color? seedColor}) {
|
||||||
ColorScheme? dynamicScheme,
|
final scheme =
|
||||||
Color? seedColor,
|
dynamicScheme ??
|
||||||
}) {
|
|
||||||
final scheme = dynamicScheme ??
|
|
||||||
ColorScheme.fromSeed(
|
ColorScheme.fromSeed(
|
||||||
seedColor: seedColor ?? defaultSeedColor,
|
seedColor: seedColor ?? defaultSeedColor,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
@@ -45,7 +43,8 @@ class AppTheme {
|
|||||||
Color? seedColor,
|
Color? seedColor,
|
||||||
bool isAmoled = false,
|
bool isAmoled = false,
|
||||||
}) {
|
}) {
|
||||||
final scheme = dynamicScheme ??
|
final scheme =
|
||||||
|
dynamicScheme ??
|
||||||
ColorScheme.fromSeed(
|
ColorScheme.fromSeed(
|
||||||
seedColor: seedColor ?? defaultSeedColor,
|
seedColor: seedColor ?? defaultSeedColor,
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
@@ -75,34 +74,41 @@ class AppTheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// AppBar theme
|
/// AppBar theme
|
||||||
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
|
static AppBarTheme _appBarTheme(
|
||||||
elevation: 0,
|
ColorScheme scheme, {
|
||||||
scrolledUnderElevation: isAmoled ? 0 : 3,
|
bool isAmoled = false,
|
||||||
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
}) => AppBarTheme(
|
||||||
foregroundColor: scheme.onSurface,
|
elevation: 0,
|
||||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
scrolledUnderElevation: isAmoled ? 0 : 3,
|
||||||
centerTitle: true,
|
backgroundColor: isAmoled ? Colors.black : scheme.surface,
|
||||||
titleTextStyle: TextStyle(
|
foregroundColor: scheme.onSurface,
|
||||||
color: scheme.onSurface,
|
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||||
fontSize: 22,
|
centerTitle: true,
|
||||||
fontWeight: FontWeight.w500,
|
titleTextStyle: TextStyle(
|
||||||
),
|
color: scheme.onSurface,
|
||||||
);
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// Card theme
|
/// Card theme
|
||||||
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(
|
||||||
color: scheme.surfaceContainerLow,
|
borderRadius: BorderRadius.circular(16),
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
), // 12 -> 16
|
||||||
);
|
color: scheme.surfaceContainerLow,
|
||||||
|
surfaceTintColor: scheme.surfaceTint,
|
||||||
|
);
|
||||||
|
|
||||||
/// Elevated button theme
|
/// Elevated button theme
|
||||||
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
|
static ElevatedButtonThemeData _elevatedButtonTheme(ColorScheme scheme) =>
|
||||||
ElevatedButtonThemeData(
|
ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -111,7 +117,9 @@ class AppTheme {
|
|||||||
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
static FilledButtonThemeData _filledButtonTheme(ColorScheme scheme) =>
|
||||||
FilledButtonThemeData(
|
FilledButtonThemeData(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -120,7 +128,9 @@ class AppTheme {
|
|||||||
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
|
static OutlinedButtonThemeData _outlinedButtonTheme(ColorScheme scheme) =>
|
||||||
OutlinedButtonThemeData(
|
OutlinedButtonThemeData(
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -129,7 +139,9 @@ class AppTheme {
|
|||||||
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
static TextButtonThemeData _textButtonTheme(ColorScheme scheme) =>
|
||||||
TextButtonThemeData(
|
TextButtonThemeData(
|
||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 20 -> 16
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -147,52 +159,63 @@ class AppTheme {
|
|||||||
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) =>
|
||||||
InputDecorationTheme(
|
InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: scheme.surfaceContainerHighest,
|
fillColor: scheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.3,
|
||||||
|
), // Added transparency
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide.none,
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide(color: scheme.primary, width: 2),
|
borderSide: BorderSide(color: scheme.primary, width: 2),
|
||||||
),
|
),
|
||||||
errorBorder: OutlineInputBorder(
|
errorBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16), // 12 -> 16
|
||||||
borderSide: BorderSide(color: scheme.error, width: 1),
|
borderSide: BorderSide(color: scheme.error, width: 1),
|
||||||
),
|
),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 16,
|
||||||
|
), // consistent padding
|
||||||
);
|
);
|
||||||
|
|
||||||
/// List tile theme
|
/// List tile theme
|
||||||
static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData(
|
static ListTileThemeData _listTileTheme(ColorScheme scheme) =>
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
ListTileThemeData(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
), // 12 -> 16
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Dialog theme
|
/// Dialog theme
|
||||||
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
static DialogThemeData _dialogTheme(ColorScheme scheme) => DialogThemeData(
|
||||||
elevation: 6,
|
elevation: 6,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||||
backgroundColor: scheme.surfaceContainerHigh,
|
backgroundColor: scheme.surfaceContainerHigh,
|
||||||
surfaceTintColor: scheme.surfaceTint,
|
surfaceTintColor: scheme.surfaceTint,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Navigation bar theme
|
/// Navigation bar theme
|
||||||
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
|
static NavigationBarThemeData _navigationBarTheme(
|
||||||
NavigationBarThemeData(
|
ColorScheme scheme, {
|
||||||
elevation: 0,
|
bool isAmoled = false,
|
||||||
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
}) => NavigationBarThemeData(
|
||||||
indicatorColor: scheme.secondaryContainer,
|
elevation: 0,
|
||||||
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
|
||||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
indicatorColor: scheme.secondaryContainer,
|
||||||
);
|
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
|
||||||
|
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
|
||||||
|
);
|
||||||
|
|
||||||
/// SnackBar theme
|
/// SnackBar theme
|
||||||
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) => SnackBarThemeData(
|
static SnackBarThemeData _snackBarTheme(ColorScheme scheme) =>
|
||||||
|
SnackBarThemeData(
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
backgroundColor: scheme.inverseSurface,
|
backgroundColor: scheme.inverseSurface,
|
||||||
@@ -200,40 +223,44 @@ class AppTheme {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Progress indicator theme
|
/// Progress indicator theme
|
||||||
static ProgressIndicatorThemeData _progressIndicatorTheme(ColorScheme scheme) =>
|
static ProgressIndicatorThemeData _progressIndicatorTheme(
|
||||||
ProgressIndicatorThemeData(
|
ColorScheme scheme,
|
||||||
color: scheme.primary,
|
) => ProgressIndicatorThemeData(
|
||||||
linearTrackColor: scheme.surfaceContainerHighest,
|
color: scheme.primary,
|
||||||
circularTrackColor: scheme.surfaceContainerHighest,
|
linearTrackColor: scheme.surfaceContainerHighest,
|
||||||
);
|
circularTrackColor: scheme.surfaceContainerHighest,
|
||||||
|
);
|
||||||
|
|
||||||
/// Switch theme
|
/// Switch theme
|
||||||
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
|
static SwitchThemeData _switchTheme(ColorScheme scheme) => SwitchThemeData(
|
||||||
thumbColor: WidgetStateProperty.resolveWith((states) {
|
thumbColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return scheme.onPrimary;
|
return scheme.onPrimary;
|
||||||
}
|
}
|
||||||
return scheme.outline;
|
return scheme.outline;
|
||||||
}),
|
}),
|
||||||
trackColor: WidgetStateProperty.resolveWith((states) {
|
trackColor: WidgetStateProperty.resolveWith((states) {
|
||||||
if (states.contains(WidgetState.selected)) {
|
if (states.contains(WidgetState.selected)) {
|
||||||
return scheme.primary;
|
return scheme.primary;
|
||||||
}
|
}
|
||||||
return scheme.surfaceContainerHighest;
|
return scheme.surfaceContainerHighest;
|
||||||
}),
|
}),
|
||||||
);
|
thumbIcon: WidgetStateProperty.resolveWith((states) {
|
||||||
|
if (states.contains(WidgetState.selected)) {
|
||||||
|
return Icon(Icons.check, color: scheme.primary);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/// Chip theme
|
/// Chip theme
|
||||||
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
backgroundColor: scheme.surfaceContainerLow,
|
backgroundColor: scheme.surfaceContainerLow,
|
||||||
selectedColor: scheme.secondaryContainer,
|
selectedColor: scheme.secondaryContainer,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Divider theme
|
/// Divider theme
|
||||||
static DividerThemeData _dividerTheme(ColorScheme scheme) => DividerThemeData(
|
static DividerThemeData _dividerTheme(ColorScheme scheme) =>
|
||||||
color: scheme.outlineVariant,
|
DividerThemeData(color: scheme.outlineVariant, thickness: 1, space: 1);
|
||||||
thickness: 1,
|
|
||||||
space: 1,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-17
@@ -50,6 +50,7 @@ class LogBuffer extends ChangeNotifier {
|
|||||||
int _lastGoLogIndex = 0;
|
int _lastGoLogIndex = 0;
|
||||||
|
|
||||||
/// Whether logging is enabled (controlled by settings)
|
/// Whether logging is enabled (controlled by settings)
|
||||||
|
/// User must enable "Detailed Logging" in settings to capture logs
|
||||||
static bool _loggingEnabled = false;
|
static bool _loggingEnabled = false;
|
||||||
static bool get loggingEnabled => _loggingEnabled;
|
static bool get loggingEnabled => _loggingEnabled;
|
||||||
static set loggingEnabled(bool value) {
|
static set loggingEnabled(bool value) {
|
||||||
@@ -242,39 +243,63 @@ final log = Logger(
|
|||||||
|
|
||||||
/// Logger with class/tag prefix for better traceability
|
/// Logger with class/tag prefix for better traceability
|
||||||
/// Now also writes to LogBuffer for in-app viewing
|
/// Now also writes to LogBuffer for in-app viewing
|
||||||
|
/// Works in both debug and release mode
|
||||||
class AppLogger {
|
class AppLogger {
|
||||||
final String _tag;
|
final String _tag;
|
||||||
late final Logger _logger;
|
late final Logger? _logger;
|
||||||
|
|
||||||
AppLogger(this._tag) {
|
AppLogger(this._tag) {
|
||||||
_logger = Logger(
|
// Only create Logger instance in debug mode
|
||||||
printer: SimplePrinter(printTime: false, colors: false),
|
// In release mode, we write directly to LogBuffer
|
||||||
output: BufferedOutput(_tag),
|
if (kDebugMode) {
|
||||||
level: Level.debug,
|
_logger = Logger(
|
||||||
);
|
printer: SimplePrinter(printTime: false, colors: false),
|
||||||
|
output: BufferedOutput(_tag),
|
||||||
|
level: Level.debug,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_logger = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addToBuffer(String level, String message, {String? error}) {
|
||||||
|
LogBuffer().add(LogEntry(
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
level: level,
|
||||||
|
tag: _tag,
|
||||||
|
message: message,
|
||||||
|
error: error,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
void d(String message) {
|
void d(String message) {
|
||||||
_logger.d(message);
|
if (kDebugMode) {
|
||||||
|
_logger?.d(message);
|
||||||
|
} else {
|
||||||
|
// In release mode, write directly to buffer
|
||||||
|
_addToBuffer('DEBUG', message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void i(String message) {
|
void i(String message) {
|
||||||
_logger.i(message);
|
if (kDebugMode) {
|
||||||
|
_logger?.i(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('INFO', message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void w(String message) {
|
void w(String message) {
|
||||||
_logger.w(message);
|
if (kDebugMode) {
|
||||||
|
_logger?.w(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('WARN', message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void e(String message, [Object? error, StackTrace? stackTrace]) {
|
void e(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
LogBuffer().add(LogEntry(
|
_addToBuffer('ERROR', message, error: error.toString());
|
||||||
timestamp: DateTime.now(),
|
|
||||||
level: 'ERROR',
|
|
||||||
tag: _tag,
|
|
||||||
message: message,
|
|
||||||
error: error.toString(),
|
|
||||||
));
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
debugPrint('[$_tag] ERROR: $message | $error');
|
debugPrint('[$_tag] ERROR: $message | $error');
|
||||||
if (stackTrace != null) {
|
if (stackTrace != null) {
|
||||||
@@ -282,7 +307,11 @@ class AppLogger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_logger.e(message);
|
if (kDebugMode) {
|
||||||
|
_logger?.e(message);
|
||||||
|
} else {
|
||||||
|
_addToBuffer('ERROR', message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.2.5+47
|
version: 2.2.7+49
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.2.5+47
|
version: 2.2.7+49
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
Reference in New Issue
Block a user