mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 19:27:57 +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 |
+98
-65
@@ -19,9 +19,9 @@ const (
|
|||||||
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
deezerAlbumURL = deezerBaseURL + "/album/%s"
|
||||||
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
deezerArtistURL = deezerBaseURL + "/artist/%s"
|
||||||
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
deezerPlaylistURL = deezerBaseURL + "/playlist/%s"
|
||||||
|
|
||||||
deezerCacheTTL = 10 * time.Minute
|
deezerCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
// Parallel ISRC fetching settings
|
// Parallel ISRC fetching settings
|
||||||
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
deezerMaxParallelISRC = 10 // Max concurrent ISRC fetches
|
||||||
)
|
)
|
||||||
@@ -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) ...
|
||||||
@@ -113,7 +114,7 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
|
|||||||
if albumImage == "" {
|
if albumImage == "" {
|
||||||
albumImage = track.Album.Cover
|
albumImage = track.Album.Cover
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find release date
|
// Try to find release date
|
||||||
releaseDate := track.ReleaseDate
|
releaseDate := track.ReleaseDate
|
||||||
if releaseDate == "" {
|
if releaseDate == "" {
|
||||||
@@ -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{
|
||||||
@@ -241,7 +276,7 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
|
|||||||
// GetTrack fetches a single track by Deezer ID
|
// GetTrack fetches a single track by Deezer ID
|
||||||
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
func (c *DeezerClient) GetTrack(ctx context.Context, trackID string) (*TrackResponse, error) {
|
||||||
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
trackURL := fmt.Sprintf(deezerTrackURL, trackID)
|
||||||
|
|
||||||
var track deezerTrack
|
var track deezerTrack
|
||||||
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
if err := c.getJSON(ctx, trackURL, &track); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -263,7 +298,7 @@ func (c *DeezerClient) GetAlbum(ctx context.Context, albumID string) (*AlbumResp
|
|||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
albumURL := fmt.Sprintf(deezerAlbumURL, albumID)
|
||||||
|
|
||||||
var album deezerAlbumFull
|
var album deezerAlbumFull
|
||||||
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
if err := c.getJSON(ctx, albumURL, &album); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -375,7 +410,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
if albumType == "compile" {
|
if albumType == "compile" {
|
||||||
albumType = "compilation"
|
albumType = "compilation"
|
||||||
}
|
}
|
||||||
|
|
||||||
coverURL := album.CoverXL
|
coverURL := album.CoverXL
|
||||||
if coverURL == "" {
|
if coverURL == "" {
|
||||||
coverURL = album.CoverBig
|
coverURL = album.CoverBig
|
||||||
@@ -418,7 +453,7 @@ func (c *DeezerClient) GetArtist(ctx context.Context, artistID string) (*ArtistR
|
|||||||
// ISRC is fetched in parallel for better performance
|
// ISRC is fetched in parallel for better performance
|
||||||
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
func (c *DeezerClient) GetPlaylist(ctx context.Context, playlistID string) (*PlaylistResponsePayload, error) {
|
||||||
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
playlistURL := fmt.Sprintf(deezerPlaylistURL, playlistID)
|
||||||
|
|
||||||
var playlist deezerPlaylistFull
|
var playlist deezerPlaylistFull
|
||||||
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
if err := c.getJSON(ctx, playlistURL, &playlist); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -482,7 +517,7 @@ func (c *DeezerClient) SearchByISRC(ctx context.Context, isrc string) (*TrackMet
|
|||||||
// Use direct ISRC endpoint (API 2.0)
|
// Use direct ISRC endpoint (API 2.0)
|
||||||
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
// https://api.deezer.com/2.0/track/isrc:{ISRC}
|
||||||
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
directURL := fmt.Sprintf("%s/track/isrc:%s", deezerBaseURL, isrc)
|
||||||
|
|
||||||
var track deezerTrack
|
var track deezerTrack
|
||||||
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
if err := c.getJSON(ctx, directURL, &track); err != nil {
|
||||||
// Fallback to search if direct endpoint fails
|
// Fallback to search if direct endpoint fails
|
||||||
@@ -522,7 +557,7 @@ func (c *DeezerClient) fetchFullTrack(ctx context.Context, trackID string) (*dee
|
|||||||
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTrack) map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
var resultMu sync.Mutex
|
var resultMu sync.Mutex
|
||||||
|
|
||||||
// First, check cache for existing ISRCs
|
// First, check cache for existing ISRCs
|
||||||
var tracksToFetch []deezerTrack
|
var tracksToFetch []deezerTrack
|
||||||
c.cacheMu.RLock()
|
c.cacheMu.RLock()
|
||||||
@@ -535,20 +570,20 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
if len(tracksToFetch) == 0 {
|
if len(tracksToFetch) == 0 {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use semaphore to limit concurrent requests
|
// Use semaphore to limit concurrent requests
|
||||||
sem := make(chan struct{}, deezerMaxParallelISRC)
|
sem := make(chan struct{}, deezerMaxParallelISRC)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
for _, track := range tracksToFetch {
|
for _, track := range tracksToFetch {
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(t deezerTrack) {
|
go func(t deezerTrack) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
// Acquire semaphore
|
// Acquire semaphore
|
||||||
select {
|
select {
|
||||||
case sem <- struct{}{}:
|
case sem <- struct{}{}:
|
||||||
@@ -556,24 +591,24 @@ func (c *DeezerClient) fetchISRCsParallel(ctx context.Context, tracks []deezerTr
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
trackIDStr := fmt.Sprintf("%d", t.ID)
|
trackIDStr := fmt.Sprintf("%d", t.ID)
|
||||||
fullTrack, err := c.fetchFullTrack(ctx, trackIDStr)
|
fullTrack, err := c.fetchFullTrack(ctx, trackIDStr)
|
||||||
if err != nil || fullTrack == nil {
|
if err != nil || fullTrack == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store in result and cache
|
// Store in result and cache
|
||||||
resultMu.Lock()
|
resultMu.Lock()
|
||||||
result[trackIDStr] = fullTrack.ISRC
|
result[trackIDStr] = fullTrack.ISRC
|
||||||
resultMu.Unlock()
|
resultMu.Unlock()
|
||||||
|
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
c.isrcCache[trackIDStr] = fullTrack.ISRC
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
}(track)
|
}(track)
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -588,23 +623,21 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
|
|||||||
return isrc, nil
|
return isrc, nil
|
||||||
}
|
}
|
||||||
c.cacheMu.RUnlock()
|
c.cacheMu.RUnlock()
|
||||||
|
|
||||||
// Fetch from API
|
// Fetch from API
|
||||||
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
fullTrack, err := c.fetchFullTrack(ctx, trackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result
|
// Cache the result
|
||||||
c.cacheMu.Lock()
|
c.cacheMu.Lock()
|
||||||
c.isrcCache[trackID] = fullTrack.ISRC
|
c.isrcCache[trackID] = fullTrack.ISRC
|
||||||
c.cacheMu.Unlock()
|
c.cacheMu.Unlock()
|
||||||
|
|
||||||
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
|
||||||
@@ -687,7 +720,7 @@ func parseDeezerURL(input string) (string, string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||||
|
|
||||||
// Skip language prefix if present (e.g., /en/, /fr/)
|
// Skip language prefix if present (e.g., /en/, /fr/)
|
||||||
if len(parts) > 0 && len(parts[0]) == 2 {
|
if len(parts) > 0 && len(parts[0]) == 2 {
|
||||||
parts = parts[1:]
|
parts = parts[1:]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -980,7 +987,7 @@ func errorResponse(msg string) (string, error) {
|
|||||||
errorType := "unknown"
|
errorType := "unknown"
|
||||||
lowerMsg := strings.ToLower(msg)
|
lowerMsg := strings.ToLower(msg)
|
||||||
|
|
||||||
if strings.Contains(lowerMsg, "isp blocking") ||
|
if strings.Contains(lowerMsg, "isp blocking") ||
|
||||||
strings.Contains(lowerMsg, "try using vpn") ||
|
strings.Contains(lowerMsg, "try using vpn") ||
|
||||||
strings.Contains(lowerMsg, "change dns") {
|
strings.Contains(lowerMsg, "change dns") {
|
||||||
errorType = "isp_blocked"
|
errorType = "isp_blocked"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -143,11 +143,11 @@ func LogError(tag, format string, args ...interface{}) {
|
|||||||
func GoLog(format string, args ...interface{}) {
|
func GoLog(format string, args ...interface{}) {
|
||||||
message := fmt.Sprintf(format, args...)
|
message := fmt.Sprintf(format, args...)
|
||||||
message = strings.TrimSuffix(message, "\n")
|
message = strings.TrimSuffix(message, "\n")
|
||||||
|
|
||||||
// Extract tag from message if present (e.g., "[Tidal] message")
|
// Extract tag from message if present (e.g., "[Tidal] message")
|
||||||
tag := "Go"
|
tag := "Go"
|
||||||
level := "INFO"
|
level := "INFO"
|
||||||
|
|
||||||
if strings.HasPrefix(message, "[") {
|
if strings.HasPrefix(message, "[") {
|
||||||
endBracket := strings.Index(message, "]")
|
endBracket := strings.Index(message, "]")
|
||||||
if endBracket > 1 {
|
if endBracket > 1 {
|
||||||
@@ -155,7 +155,7 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
message = strings.TrimSpace(message[endBracket+1:])
|
message = strings.TrimSpace(message[endBracket+1:])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine level from message content
|
// Determine level from message content
|
||||||
msgLower := strings.ToLower(message)
|
msgLower := strings.ToLower(message)
|
||||||
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
if strings.Contains(msgLower, "error") || strings.Contains(msgLower, "failed") || strings.HasPrefix(message, "✗") {
|
||||||
@@ -167,7 +167,7 @@ func GoLog(format string, args ...interface{}) {
|
|||||||
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
} else if strings.Contains(msgLower, "searching") || strings.Contains(msgLower, "trying") || strings.Contains(msgLower, "found") {
|
||||||
level = "DEBUG"
|
level = "DEBUG"
|
||||||
}
|
}
|
||||||
|
|
||||||
GetLogBuffer().Add(level, tag, message)
|
GetLogBuffer().Add(level, tag, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-52
@@ -58,7 +58,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
if metadata.TotalTracks > 0 {
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||||
@@ -66,15 +66,15 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
setComment(cmt, "ISRC", metadata.ISRC)
|
setComment(cmt, "ISRC", metadata.ISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Description != "" {
|
if metadata.Description != "" {
|
||||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
@@ -105,7 +105,7 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
|
|||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picture, err := flacpicture.NewFromImageData(
|
||||||
flacpicture.PictureTypeFrontCover,
|
flacpicture.PictureTypeFrontCover,
|
||||||
"Front Cover",
|
"Front Cover",
|
||||||
@@ -162,7 +162,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "ALBUM", metadata.Album)
|
setComment(cmt, "ALBUM", metadata.Album)
|
||||||
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
|
||||||
setComment(cmt, "DATE", metadata.Date)
|
setComment(cmt, "DATE", metadata.Date)
|
||||||
|
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
if metadata.TotalTracks > 0 {
|
if metadata.TotalTracks > 0 {
|
||||||
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
setComment(cmt, "TRACKNUMBER", fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks))
|
||||||
@@ -170,15 +170,15 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
setComment(cmt, "TRACKNUMBER", strconv.Itoa(metadata.TrackNumber))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
setComment(cmt, "DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.ISRC != "" {
|
if metadata.ISRC != "" {
|
||||||
setComment(cmt, "ISRC", metadata.ISRC)
|
setComment(cmt, "ISRC", metadata.ISRC)
|
||||||
}
|
}
|
||||||
|
|
||||||
if metadata.Description != "" {
|
if metadata.Description != "" {
|
||||||
setComment(cmt, "DESCRIPTION", metadata.Description)
|
setComment(cmt, "DESCRIPTION", metadata.Description)
|
||||||
}
|
}
|
||||||
@@ -204,7 +204,7 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
|
|||||||
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
f.Meta = append(f.Meta[:i], f.Meta[i+1:]...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
picture, err := flacpicture.NewFromImageData(
|
picture, err := flacpicture.NewFromImageData(
|
||||||
flacpicture.PictureTypeFrontCover,
|
flacpicture.PictureTypeFrontCover,
|
||||||
"Front Cover",
|
"Front Cover",
|
||||||
@@ -276,7 +276,7 @@ func ReadMetadata(filePath string) (*Metadata, error) {
|
|||||||
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
fmt.Sscanf(discNum, "%d", &metadata.DiscNumber)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try DATE variants
|
// Try DATE variants
|
||||||
if metadata.Date == "" {
|
if metadata.Date == "" {
|
||||||
metadata.Date = getComment(cmt, "YEAR")
|
metadata.Date = getComment(cmt, "YEAR")
|
||||||
@@ -380,13 +380,13 @@ func ExtractLyrics(filePath string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try LYRICS tag first
|
// Try LYRICS tag first
|
||||||
lyrics, err := cmt.Get("LYRICS")
|
lyrics, err := cmt.Get("LYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
return lyrics[0], nil
|
return lyrics[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to UNSYNCEDLYRICS
|
// Fallback to UNSYNCEDLYRICS
|
||||||
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
|
||||||
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
if err == nil && len(lyrics) > 0 && lyrics[0] != "" {
|
||||||
@@ -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
|
||||||
@@ -419,7 +420,7 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
if _, err := file.Read(marker); err != nil {
|
if _, err := file.Read(marker); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read marker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a FLAC file
|
// Check if it's a FLAC file
|
||||||
if string(marker) == "fLaC" {
|
if string(marker) == "fLaC" {
|
||||||
// Continue reading FLAC metadata
|
// Continue reading FLAC metadata
|
||||||
@@ -446,12 +447,20 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
// Check if it's an M4A/MP4 file (starts with size + "ftyp")
|
||||||
// First 4 bytes are size, next 4 should be "ftyp"
|
// First 4 bytes are size, next 4 should be "ftyp"
|
||||||
file.Seek(0, 0) // Reset to beginning
|
file.Seek(0, 0) // Reset to beginning
|
||||||
@@ -459,17 +468,16 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
|
|||||||
if _, err := file.Read(header8); err != nil {
|
if _, err := file.Read(header8); err != nil {
|
||||||
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
return AudioQuality{}, fmt.Errorf("failed to read header: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if string(header8[4:8]) == "ftyp" {
|
if string(header8[4:8]) == "ftyp" {
|
||||||
// It's an M4A/MP4 file, use M4A quality reader
|
// It's an M4A/MP4 file, use M4A quality reader
|
||||||
file.Close() // Close before calling GetM4AQuality which opens the file again
|
file.Close() // Close before calling GetM4AQuality which opens the file again
|
||||||
return GetM4AQuality(filePath)
|
return GetM4AQuality(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -492,16 +500,16 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
// Find udta atom inside moov, or create one
|
// Find udta atom inside moov, or create one
|
||||||
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
moovSize := int(data[moovPos]<<24 | data[moovPos+1]<<16 | data[moovPos+2]<<8 | data[moovPos+3])
|
||||||
udtaPos := findAtom(data, "udta", moovPos+8)
|
udtaPos := findAtom(data, "udta", moovPos+8)
|
||||||
|
|
||||||
// Build new metadata atoms
|
// Build new metadata atoms
|
||||||
metaAtom := buildMetaAtom(metadata, coverData)
|
metaAtom := buildMetaAtom(metadata, coverData)
|
||||||
|
|
||||||
var newData []byte
|
var newData []byte
|
||||||
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
if udtaPos >= 0 && udtaPos < moovPos+moovSize {
|
||||||
// udta exists, find meta inside it or replace
|
// udta exists, find meta inside it or replace
|
||||||
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
udtaSize := int(data[udtaPos]<<24 | data[udtaPos+1]<<16 | data[udtaPos+2]<<8 | data[udtaPos+3])
|
||||||
metaPos := findAtom(data, "meta", udtaPos+8)
|
metaPos := findAtom(data, "meta", udtaPos+8)
|
||||||
|
|
||||||
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
if metaPos >= 0 && metaPos < udtaPos+udtaSize {
|
||||||
// Replace existing meta atom
|
// Replace existing meta atom
|
||||||
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
metaSize := int(data[metaPos]<<24 | data[metaPos+1]<<16 | data[metaPos+2]<<8 | data[metaPos+3])
|
||||||
@@ -519,7 +527,7 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
newUdta[3] = byte(newUdtaSize)
|
newUdta[3] = byte(newUdtaSize)
|
||||||
newUdta = append(newUdta, []byte("udta")...)
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
newUdta = append(newUdta, newUdtaContent...)
|
newUdta = append(newUdta, newUdtaContent...)
|
||||||
|
|
||||||
newData = append(newData, data[:udtaPos]...)
|
newData = append(newData, data[:udtaPos]...)
|
||||||
newData = append(newData, newUdta...)
|
newData = append(newData, newUdta...)
|
||||||
newData = append(newData, data[udtaPos+udtaSize:]...)
|
newData = append(newData, data[udtaPos+udtaSize:]...)
|
||||||
@@ -535,14 +543,14 @@ func EmbedM4AMetadata(filePath string, metadata Metadata, coverData []byte) erro
|
|||||||
newUdta[3] = byte(udtaSize)
|
newUdta[3] = byte(udtaSize)
|
||||||
newUdta = append(newUdta, []byte("udta")...)
|
newUdta = append(newUdta, []byte("udta")...)
|
||||||
newUdta = append(newUdta, udtaContent...)
|
newUdta = append(newUdta, udtaContent...)
|
||||||
|
|
||||||
// Insert udta at end of moov
|
// Insert udta at end of moov
|
||||||
insertPos := moovPos + moovSize
|
insertPos := moovPos + moovSize
|
||||||
newData = append(newData, data[:insertPos]...)
|
newData = append(newData, data[:insertPos]...)
|
||||||
newData = append(newData, newUdta...)
|
newData = append(newData, newUdta...)
|
||||||
newData = append(newData, data[insertPos:]...)
|
newData = append(newData, data[insertPos:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update moov size
|
// Update moov size
|
||||||
newMoovSize := moovSize + len(newData) - len(data)
|
newMoovSize := moovSize + len(newData) - len(data)
|
||||||
newData[moovPos] = byte(newMoovSize >> 24)
|
newData[moovPos] = byte(newMoovSize >> 24)
|
||||||
@@ -579,52 +587,52 @@ func findAtom(data []byte, name string, offset int) int {
|
|||||||
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
||||||
// Build ilst content
|
// Build ilst content
|
||||||
var ilst []byte
|
var ilst []byte
|
||||||
|
|
||||||
// ©nam - Title
|
// ©nam - Title
|
||||||
if metadata.Title != "" {
|
if metadata.Title != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
ilst = append(ilst, buildTextAtom("©nam", metadata.Title)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©ART - Artist
|
// ©ART - Artist
|
||||||
if metadata.Artist != "" {
|
if metadata.Artist != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
ilst = append(ilst, buildTextAtom("©ART", metadata.Artist)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©alb - Album
|
// ©alb - Album
|
||||||
if metadata.Album != "" {
|
if metadata.Album != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
ilst = append(ilst, buildTextAtom("©alb", metadata.Album)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// aART - Album Artist
|
// aART - Album Artist
|
||||||
if metadata.AlbumArtist != "" {
|
if metadata.AlbumArtist != "" {
|
||||||
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
ilst = append(ilst, buildTextAtom("aART", metadata.AlbumArtist)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©day - Year/Date
|
// ©day - Year/Date
|
||||||
if metadata.Date != "" {
|
if metadata.Date != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
ilst = append(ilst, buildTextAtom("©day", metadata.Date)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// trkn - Track Number
|
// trkn - Track Number
|
||||||
if metadata.TrackNumber > 0 {
|
if metadata.TrackNumber > 0 {
|
||||||
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
ilst = append(ilst, buildTrackNumberAtom(metadata.TrackNumber, metadata.TotalTracks)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk - Disc Number
|
// disk - Disc Number
|
||||||
if metadata.DiscNumber > 0 {
|
if metadata.DiscNumber > 0 {
|
||||||
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
ilst = append(ilst, buildDiscNumberAtom(metadata.DiscNumber, 0)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ©lyr - Lyrics
|
// ©lyr - Lyrics
|
||||||
if metadata.Lyrics != "" {
|
if metadata.Lyrics != "" {
|
||||||
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
ilst = append(ilst, buildTextAtom("©lyr", metadata.Lyrics)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// covr - Cover Art
|
// covr - Cover Art
|
||||||
if len(coverData) > 0 {
|
if len(coverData) > 0 {
|
||||||
ilst = append(ilst, buildCoverAtom(coverData)...)
|
ilst = append(ilst, buildCoverAtom(coverData)...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build ilst atom
|
// Build ilst atom
|
||||||
ilstSize := 8 + len(ilst)
|
ilstSize := 8 + len(ilst)
|
||||||
ilstAtom := make([]byte, 4)
|
ilstAtom := make([]byte, 4)
|
||||||
@@ -634,7 +642,7 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
ilstAtom[3] = byte(ilstSize)
|
ilstAtom[3] = byte(ilstSize)
|
||||||
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
ilstAtom = append(ilstAtom, []byte("ilst")...)
|
||||||
ilstAtom = append(ilstAtom, ilst...)
|
ilstAtom = append(ilstAtom, ilst...)
|
||||||
|
|
||||||
// Build hdlr atom (required for meta)
|
// Build hdlr atom (required for meta)
|
||||||
hdlr := []byte{
|
hdlr := []byte{
|
||||||
0, 0, 0, 33, // size = 33
|
0, 0, 0, 33, // size = 33
|
||||||
@@ -647,11 +655,11 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
0, 0, 0, 0, // component flags mask
|
0, 0, 0, 0, // component flags mask
|
||||||
0, // null terminator
|
0, // null terminator
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build meta atom
|
// Build meta atom
|
||||||
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
metaContent := append([]byte{0, 0, 0, 0}, hdlr...) // version + flags + hdlr
|
||||||
metaContent = append(metaContent, ilstAtom...)
|
metaContent = append(metaContent, ilstAtom...)
|
||||||
|
|
||||||
metaSize := 8 + len(metaContent)
|
metaSize := 8 + len(metaContent)
|
||||||
metaAtom := make([]byte, 4)
|
metaAtom := make([]byte, 4)
|
||||||
metaAtom[0] = byte(metaSize >> 24)
|
metaAtom[0] = byte(metaSize >> 24)
|
||||||
@@ -660,14 +668,14 @@ func buildMetaAtom(metadata Metadata, coverData []byte) []byte {
|
|||||||
metaAtom[3] = byte(metaSize)
|
metaAtom[3] = byte(metaSize)
|
||||||
metaAtom = append(metaAtom, []byte("meta")...)
|
metaAtom = append(metaAtom, []byte("meta")...)
|
||||||
metaAtom = append(metaAtom, metaContent...)
|
metaAtom = append(metaAtom, metaContent...)
|
||||||
|
|
||||||
return metaAtom
|
return metaAtom
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
// buildTextAtom builds a text metadata atom (©nam, ©ART, etc.)
|
||||||
func buildTextAtom(name, value string) []byte {
|
func buildTextAtom(name, value string) []byte {
|
||||||
valueBytes := []byte(value)
|
valueBytes := []byte(value)
|
||||||
|
|
||||||
// data atom
|
// data atom
|
||||||
dataSize := 16 + len(valueBytes)
|
dataSize := 16 + len(valueBytes)
|
||||||
dataAtom := make([]byte, 4)
|
dataAtom := make([]byte, 4)
|
||||||
@@ -679,7 +687,7 @@ func buildTextAtom(name, value string) []byte {
|
|||||||
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
dataAtom = append(dataAtom, 0, 0, 0, 1) // type = UTF-8
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
dataAtom = append(dataAtom, valueBytes...)
|
dataAtom = append(dataAtom, valueBytes...)
|
||||||
|
|
||||||
// container atom
|
// container atom
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
@@ -689,7 +697,7 @@ func buildTextAtom(name, value string) []byte {
|
|||||||
atom[3] = byte(atomSize)
|
atom[3] = byte(atomSize)
|
||||||
atom = append(atom, []byte(name)...)
|
atom = append(atom, []byte(name)...)
|
||||||
atom = append(atom, dataAtom...)
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
return atom
|
return atom
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,7 +714,7 @@ func buildTrackNumberAtom(track, total int) []byte {
|
|||||||
byte(total >> 8), byte(total), // total tracks
|
byte(total >> 8), byte(total), // total tracks
|
||||||
0, 0, // padding
|
0, 0, // padding
|
||||||
}
|
}
|
||||||
|
|
||||||
// trkn atom
|
// trkn atom
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
@@ -716,7 +724,7 @@ func buildTrackNumberAtom(track, total int) []byte {
|
|||||||
atom[3] = byte(atomSize)
|
atom[3] = byte(atomSize)
|
||||||
atom = append(atom, []byte("trkn")...)
|
atom = append(atom, []byte("trkn")...)
|
||||||
atom = append(atom, dataAtom...)
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
return atom
|
return atom
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -732,7 +740,7 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
|||||||
byte(disc >> 8), byte(disc), // disc number
|
byte(disc >> 8), byte(disc), // disc number
|
||||||
byte(total >> 8), byte(total), // total discs
|
byte(total >> 8), byte(total), // total discs
|
||||||
}
|
}
|
||||||
|
|
||||||
// disk atom
|
// disk atom
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
@@ -742,7 +750,7 @@ func buildDiscNumberAtom(disc, total int) []byte {
|
|||||||
atom[3] = byte(atomSize)
|
atom[3] = byte(atomSize)
|
||||||
atom = append(atom, []byte("disk")...)
|
atom = append(atom, []byte("disk")...)
|
||||||
atom = append(atom, dataAtom...)
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
return atom
|
return atom
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,7 +761,7 @@ func buildCoverAtom(coverData []byte) []byte {
|
|||||||
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
if len(coverData) > 8 && coverData[0] == 0x89 && coverData[1] == 'P' && coverData[2] == 'N' && coverData[3] == 'G' {
|
||||||
imageType = 14 // PNG
|
imageType = 14 // PNG
|
||||||
}
|
}
|
||||||
|
|
||||||
// data atom
|
// data atom
|
||||||
dataSize := 16 + len(coverData)
|
dataSize := 16 + len(coverData)
|
||||||
dataAtom := make([]byte, 4)
|
dataAtom := make([]byte, 4)
|
||||||
@@ -765,7 +773,7 @@ func buildCoverAtom(coverData []byte) []byte {
|
|||||||
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
dataAtom = append(dataAtom, 0, 0, 0, imageType) // type = JPEG or PNG
|
||||||
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
dataAtom = append(dataAtom, 0, 0, 0, 0) // locale
|
||||||
dataAtom = append(dataAtom, coverData...)
|
dataAtom = append(dataAtom, coverData...)
|
||||||
|
|
||||||
// covr atom
|
// covr atom
|
||||||
atomSize := 8 + len(dataAtom)
|
atomSize := 8 + len(dataAtom)
|
||||||
atom := make([]byte, 4)
|
atom := make([]byte, 4)
|
||||||
@@ -775,7 +783,7 @@ func buildCoverAtom(coverData []byte) []byte {
|
|||||||
atom[3] = byte(atomSize)
|
atom[3] = byte(atomSize)
|
||||||
atom = append(atom, []byte("covr")...)
|
atom = append(atom, []byte("covr")...)
|
||||||
atom = append(atom, dataAtom...)
|
atom = append(atom, dataAtom...)
|
||||||
|
|
||||||
return atom
|
return atom
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -128,7 +128,7 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
|
|||||||
// Extract core title (before any parentheses/brackets)
|
// Extract core title (before any parentheses/brackets)
|
||||||
coreExpected := qobuzExtractCoreTitle(normExpected)
|
coreExpected := qobuzExtractCoreTitle(normExpected)
|
||||||
coreFound := qobuzExtractCoreTitle(normFound)
|
coreFound := qobuzExtractCoreTitle(normFound)
|
||||||
|
|
||||||
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
if coreExpected != "" && coreFound != "" && coreExpected == coreFound {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ func qobuzExtractCoreTitle(title string) string {
|
|||||||
parenIdx := strings.Index(title, "(")
|
parenIdx := strings.Index(title, "(")
|
||||||
bracketIdx := strings.Index(title, "[")
|
bracketIdx := strings.Index(title, "[")
|
||||||
dashIdx := strings.Index(title, " - ")
|
dashIdx := strings.Index(title, " - ")
|
||||||
|
|
||||||
cutIdx := len(title)
|
cutIdx := len(title)
|
||||||
if parenIdx > 0 && parenIdx < cutIdx {
|
if parenIdx > 0 && parenIdx < cutIdx {
|
||||||
cutIdx = parenIdx
|
cutIdx = parenIdx
|
||||||
@@ -162,7 +162,7 @@ func qobuzExtractCoreTitle(title string) string {
|
|||||||
if dashIdx > 0 && dashIdx < cutIdx {
|
if dashIdx > 0 && dashIdx < cutIdx {
|
||||||
cutIdx = dashIdx
|
cutIdx = dashIdx
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.TrimSpace(title[:cutIdx])
|
return strings.TrimSpace(title[:cutIdx])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,11 +173,11 @@ func qobuzCleanTitle(title string) string {
|
|||||||
// Remove content in parentheses/brackets that are version indicators
|
// Remove content in parentheses/brackets that are version indicators
|
||||||
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
|
// This helps match "Song (Remastered)" with "Song" or "Song (2024 Remaster)"
|
||||||
versionPatterns := []string{
|
versionPatterns := []string{
|
||||||
"remaster", "remastered", "deluxe", "bonus", "single",
|
"remaster", "remastered", "deluxe", "bonus", "single",
|
||||||
"album version", "radio edit", "original mix", "extended",
|
"album version", "radio edit", "original mix", "extended",
|
||||||
"club mix", "remix", "live", "acoustic", "demo",
|
"club mix", "remix", "live", "acoustic", "demo",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove parenthetical content if it contains version indicators
|
// Remove parenthetical content if it contains version indicators
|
||||||
for {
|
for {
|
||||||
startParen := strings.LastIndex(cleaned, "(")
|
startParen := strings.LastIndex(cleaned, "(")
|
||||||
@@ -198,7 +198,7 @@ func qobuzCleanTitle(title string) string {
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same for brackets
|
// Same for brackets
|
||||||
for {
|
for {
|
||||||
startBracket := strings.LastIndex(cleaned, "[")
|
startBracket := strings.LastIndex(cleaned, "[")
|
||||||
@@ -370,7 +370,7 @@ func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) {
|
|||||||
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
// expectedDurationSec is the expected duration in seconds (0 to skip verification)
|
||||||
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
func (q *QobuzDownloader) SearchTrackByISRCWithDuration(isrc string, expectedDurationSec int) (*QobuzTrack, error) {
|
||||||
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
GoLog("[Qobuz] Searching by ISRC: %s\n", isrc)
|
||||||
|
|
||||||
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
|
||||||
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID)
|
||||||
|
|
||||||
@@ -602,12 +602,12 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
// Return best quality among duration matches
|
// Return best quality among duration matches
|
||||||
for _, track := range durationMatches {
|
for _, track := range durationMatches {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified, hi-res)\n",
|
||||||
track.Title, track.Performer.Name)
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title+duration verified)\n",
|
||||||
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
durationMatches[0].Title, durationMatches[0].Performer.Name)
|
||||||
return durationMatches[0], nil
|
return durationMatches[0], nil
|
||||||
}
|
}
|
||||||
@@ -619,18 +619,18 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
|
|||||||
// No duration verification, return best quality from title matches
|
// No duration verification, return best quality from title matches
|
||||||
for _, track := range tracksToCheck {
|
for _, track := range tracksToCheck {
|
||||||
if track.MaximumBitDepth >= 24 {
|
if track.MaximumBitDepth >= 24 {
|
||||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified, hi-res)\n",
|
||||||
track.Title, track.Performer.Name)
|
track.Title, track.Performer.Name)
|
||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(tracksToCheck) > 0 {
|
if len(tracksToCheck) > 0 {
|
||||||
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
GoLog("[Qobuz] ✓ Match found: '%s' by '%s' (title verified)\n",
|
||||||
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
tracksToCheck[0].Title, tracksToCheck[0].Performer.Name)
|
||||||
return tracksToCheck[0], nil
|
return tracksToCheck[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+154
-7
@@ -477,7 +477,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
|
|
||||||
if len(result.Items) > 0 {
|
if len(result.Items) > 0 {
|
||||||
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
GoLog("[Tidal] Found %d results for '%s'\n", len(result.Items), cleanQuery)
|
||||||
|
|
||||||
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
|
// OPTIMIZATION: If ISRC provided, check for match immediately and return early
|
||||||
if spotifyISRC != "" {
|
if spotifyISRC != "" {
|
||||||
for i := range result.Items {
|
for i := range result.Items {
|
||||||
@@ -494,7 +494,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
return track, nil
|
return track, nil
|
||||||
}
|
}
|
||||||
// Duration mismatch, continue searching
|
// Duration mismatch, continue searching
|
||||||
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
GoLog("[Tidal] ISRC match but duration mismatch (expected %ds, got %ds), continuing...\n",
|
||||||
expectedDuration, track.Duration)
|
expectedDuration, track.Duration)
|
||||||
} else {
|
} else {
|
||||||
GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
GoLog("[Tidal] ✓ ISRC match: '%s'\n", track.Title)
|
||||||
@@ -503,7 +503,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allTracks = append(allTracks, result.Items...)
|
allTracks = append(allTracks, result.Items...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
@@ -1390,7 +1537,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
GoLog("[Tidal] ISRC search failed, trying SongLink...\n")
|
||||||
var tidalURL string
|
var tidalURL string
|
||||||
var slErr error
|
var slErr error
|
||||||
|
|
||||||
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
// Check if SpotifyID is actually a Deezer ID (format: "deezer:xxxxx")
|
||||||
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
if strings.HasPrefix(req.SpotifyID, "deezer:") {
|
||||||
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:")
|
||||||
@@ -1400,7 +1547,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
} else {
|
} else {
|
||||||
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if slErr == nil && tidalURL != "" {
|
if slErr == nil && tidalURL != "" {
|
||||||
// Extract track ID and get track info
|
// Extract track ID and get track info
|
||||||
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
trackID, idErr := downloader.GetTrackIDFromURL(tidalURL)
|
||||||
@@ -1456,7 +1603,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
|
|||||||
}
|
}
|
||||||
tidalArtist = strings.Join(artistNames, ", ")
|
tidalArtist = strings.Join(artistNames, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify title first
|
// Verify title first
|
||||||
if !titlesMatch(req.TrackName, track.Title) {
|
if !titlesMatch(req.TrackName, track.Title) {
|
||||||
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
GoLog("[Tidal] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,11 +136,275 @@ 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;
|
||||||
final double topPadding;
|
final double topPadding;
|
||||||
|
|
||||||
const _AppBarTitle({required this.title, required this.topPadding});
|
const _AppBarTitle({required this.title, required this.topPadding});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -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,27 +480,41 @@ 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) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Unselected chips need contrast with card background
|
// Unselected chips need contrast with card background
|
||||||
// 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(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
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,25 +609,39 @@ 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) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
// Unselected chips need contrast with card background
|
// 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(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
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,17 +607,25 @@ 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) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
final unselectedColor = isDark
|
final unselectedColor = isDark
|
||||||
? Color.alphaBlend(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(
|
||||||
child: Material(
|
child: Material(
|
||||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||||
@@ -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,11 +697,16 @@ 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
isSelected
|
isSelected
|
||||||
? Icon(Icons.check_circle, color: colorScheme.primary)
|
? Icon(Icons.check_circle, color: colorScheme.primary)
|
||||||
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
: Icon(Icons.circle_outlined, color: colorScheme.outline),
|
||||||
],
|
],
|
||||||
@@ -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