Compare commits

...

7 Commits

Author SHA1 Message Date
zarzet 24ef66be4c Merge branch 'main' of https://github.com/zarzet/SpotiFLAC-Mobile 2026-01-11 06:41:54 +07:00
zarzet d07a49f605 UI modernization: Global theme update, redesigned Options/Download settings, and smart filename editor 2026-01-11 06:41:34 +07:00
zarzet 4eba28db7a v2.2.7: CSV import metadata enrichment with Deezer fallback 2026-01-11 06:09:48 +07:00
zarzet b73a3f8912 Add CSV import and optimize Appearance settings 2026-01-11 05:56:30 +07:00
zarzet 9f47f2ce85 UI Modernization: Unified app bars, updated logos, improved settings & Deezer support 2026-01-11 04:28:41 +07:00
zarzet f2aca734a3 fix: improve logging for release builds and UI improvements
- Fix Flutter logs not appearing in release mode by bypassing Logger package
- Add detailed logging for Deezer search API calls
- Replace music_note icon with app logo on home screen
- Remove shadow/border from logo in About and Home screens
- Align icon size (40x40) with avatar in About page for consistent layout
2026-01-11 02:27:26 +07:00
Zarz Eleutherius 09cb637a86 Update VirusTotal link in README.md 2026-01-10 19:27:48 +07:00
26 changed files with 3237 additions and 1223 deletions
+110
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) [![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/cd205e22783a179aab80a2f0cc4445c84e59615a08c11d6e722ab4692c26ac37) [![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](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
View File
@@ -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:]
+8 -1
View File
@@ -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"
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+2 -2
View File
@@ -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
+28 -19
View File
@@ -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
View File
@@ -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),
+3
View File
@@ -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
View File
@@ -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,
),
),
);
},
), ),
), ),
+93 -20
View File
@@ -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),
),
],
);
}
}
+482 -115
View File
@@ -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,
),
),
],
),
), ),
), ),
), ),
+450 -137
View File
@@ -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
+21 -12
View File
@@ -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,
),
),
);
},
), ),
), ),
+41 -21
View File
@@ -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) {
+256
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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