Compare commits

..

9 Commits

Author SHA1 Message Date
zarzet c673581c32 feat: multi-select batch delete and album grouping in history
- Add multi-select mode with long-press to select tracks
- Add bottom action bar for selection (Material 3 style)
- Add filter tabs: All/Albums/Singles
- Add album grouping view when Albums filter selected
- Add DownloadedAlbumScreen for viewing tracks in an album
- Reactive UI updates when tracks deleted
- Auto-pop when album has <2 tracks
- Update issue templates with (Stable Version) text
- Bump version to 2.2.8
2026-01-12 06:18:32 +07:00
zarzet a6d488696b chore: add extension API feature request template and ignore docs folder 2026-01-12 01:22:23 +07:00
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
35 changed files with 4921 additions and 1603 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ body:
options: options:
- label: I have searched existing issues and this bug hasn't been reported yet - label: I have searched existing issues and this bug hasn't been reported yet
required: true required: true
- label: I am using the latest version of SpotiFLAC - label: I am using the latest version of SpotiFLAC (Stable Version)
required: true required: true
- type: textarea - type: textarea
+3
View File
@@ -3,3 +3,6 @@ contact_links:
- name: README - name: README
url: https://github.com/zarzet/SpotiFLAC-Mobile#readme url: https://github.com/zarzet/SpotiFLAC-Mobile#readme
about: Check the README for setup instructions and FAQ about: Check the README for setup instructions and FAQ
- name: Extension Development Guide
url: https://zarz.moe/docs
about: Documentation for building SpotiFLAC extensions
+1 -1
View File
@@ -16,7 +16,7 @@ body:
options: options:
- label: I have tried downloading with a different service (Tidal/Qobuz/Amazon) - label: I have tried downloading with a different service (Tidal/Qobuz/Amazon)
required: true required: true
- label: I am using the latest version of SpotiFLAC - label: I am using the latest version of SpotiFLAC (Stable Version)
required: true required: true
- type: dropdown - type: dropdown
@@ -0,0 +1,117 @@
name: Extension API Feature Request (Alpha)
description: Request new API features or capabilities for extension development (Extension system is in alpha)
title: "[Extension API]: "
labels: ["enhancement", "extension-api"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve the SpotiFLAC Extension API!
This form is for extension developers who need new features or capabilities that don't exist yet.
- type: checkboxes
id: checklist
attributes:
label: Checklist
description: Please confirm the following before submitting
options:
- label: I have read the [Extension Development Guide](https://zarz.moe/docs)
required: true
- label: I have searched existing issues and this API feature hasn't been requested yet
required: true
- type: textarea
id: extension_goal
attributes:
label: What are you trying to build?
description: Describe the extension or feature you're developing
placeholder: "I'm building an extension that downloads from [service name] / provides metadata from [source]..."
validations:
required: true
- type: textarea
id: current_limitation
attributes:
label: Current API Limitation
description: What's missing or limiting in the current extension API?
placeholder: |
The current API doesn't support:
- [missing feature 1]
- [missing feature 2]
This prevents me from...
validations:
required: true
- type: textarea
id: proposed_api
attributes:
label: Proposed API / Feature
description: Describe the API or feature you'd like to see added
placeholder: |
I would like to have:
- A new function `api.newFeature()` that does X
- A new manifest field `newOption` that enables Y
- Access to Z capability...
validations:
required: true
- type: textarea
id: use_case
attributes:
label: Use Case Example
description: Provide a code example of how you would use this feature
placeholder: |
```javascript
// Example usage in extension code
function download(request, progressCallback) {
const result = api.proposedFeature(params);
// ...
}
```
validations:
required: false
- type: dropdown
id: api_category
attributes:
label: API Category
description: What category does this feature fall under?
options:
- HTTP/Network API
- File System API
- Storage API
- FFmpeg/Audio Processing
- Manifest Options
- Runtime Functions
- UI Integration
- Authentication
- Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How critical is this for your extension?
options:
- Blocker - Cannot build my extension without this
- High - Major functionality depends on this
- Medium - Would significantly improve my extension
- Low - Nice to have
validations:
required: true
- type: textarea
id: workaround
attributes:
label: Current Workaround
description: Are you using any workaround currently? If so, describe it.
placeholder: "Currently I'm working around this by..."
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context, links to similar APIs, or examples from other platforms
placeholder: "Similar feature in other platforms: ..."
+3
View File
@@ -13,6 +13,9 @@ Thumbs.db
# Reference folder (development only) # Reference folder (development only)
referensi/ referensi/
# Documentation (hosted separately)
docs/
# Old spotiflac_android folder (moved to root) # Old spotiflac_android folder (moved to root)
spotiflac_android/ spotiflac_android/
+134
View File
@@ -1,5 +1,139 @@
# Changelog # Changelog
## [2.2.8] - 2026-01-12
### Added
- **Multi-Select Batch Delete**: Long-press tracks in History to enter selection mode
- Select multiple tracks at once
- "Select All" and "Delete Selected" actions
- Modern Material 3 bottom action bar (slides up from bottom)
- Works in both grid and list view modes
- **History Filter Tabs**: Filter history by All/Albums/Singles
- Album = tracks where album has >1 track in history
- Single = tracks where album has only 1 track in history
- Filter chips show counts for each category
- **Album Grouping View**: When "Albums" filter is selected, tracks are grouped by album
- Album cards displayed in 2-column grid with cover art and track count badge
- Tap album to open dedicated album detail screen
- Album detail shows all downloaded tracks from that album
- Multi-select delete support within album view
- Auto-navigates back when album has <2 tracks remaining
### Changed
- **Issue Templates**: Updated version confirmation checkbox to specify "(Stable Version)"
## [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

+76 -43
View File
@@ -58,27 +58,27 @@ func GetDeezerClient() *DeezerClient {
// Deezer API response types // Deezer API response types
type deezerTrack struct { type deezerTrack struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Duration int `json:"duration"` // in seconds Duration int `json:"duration"` // in seconds
TrackPosition int `json:"track_position"` TrackPosition int `json:"track_position"`
DiskNumber int `json:"disk_number"` DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"` ISRC string `json:"isrc"`
Link string `json:"link"` Link string `json:"link"`
ReleaseDate string `json:"release_date"` // Sometimes at track level ReleaseDate string `json:"release_date"` // Sometimes at track level
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Album deezerAlbumSimple `json:"album"` Album deezerAlbumSimple `json:"album"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
} }
type deezerArtist struct { type deezerArtist struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Picture string `json:"picture"` Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"` PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"` PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"` PictureXL string `json:"picture_xl"`
NbFan int `json:"nb_fan"` NbFan int `json:"nb_fan"`
} }
type deezerAlbumSimple struct { type deezerAlbumSimple struct {
@@ -90,6 +90,7 @@ type deezerAlbumSimple struct {
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` // Sometimes at album level ReleaseDate string `json:"release_date"` // Sometimes at album level
} }
// ... (skip other structs as they are fine/unchanged) ... // ... (skip other structs as they are fine/unchanged) ...
// ... (in convertTrack) ... // ... (in convertTrack) ...
@@ -137,17 +138,17 @@ func (c *DeezerClient) convertTrack(track deezerTrack) TrackMetadata {
} }
type deezerAlbumFull struct { type deezerAlbumFull struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Cover string `json:"cover"` Cover string `json:"cover"`
CoverMedium string `json:"cover_medium"` CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"` CoverBig string `json:"cover_big"`
CoverXL string `json:"cover_xl"` CoverXL string `json:"cover_xl"`
ReleaseDate string `json:"release_date"` ReleaseDate string `json:"release_date"`
NbTracks int `json:"nb_tracks"` NbTracks int `json:"nb_tracks"`
Artist deezerArtist `json:"artist"` Artist deezerArtist `json:"artist"`
Contributors []deezerArtist `json:"contributors"` Contributors []deezerArtist `json:"contributors"`
Tracks struct { Tracks struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -164,17 +165,17 @@ type deezerArtistFull struct {
} }
type deezerPlaylistFull struct { type deezerPlaylistFull struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Title string `json:"title"` Title string `json:"title"`
Picture string `json:"picture"` Picture string `json:"picture"`
PictureMedium string `json:"picture_medium"` PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"` PictureBig string `json:"picture_big"`
PictureXL string `json:"picture_xl"` PictureXL string `json:"picture_xl"`
NbTracks int `json:"nb_tracks"` NbTracks int `json:"nb_tracks"`
Creator struct { Creator struct {
Name string `json:"name"` Name string `json:"name"`
} `json:"creator"` } `json:"creator"`
Tracks struct { Tracks struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
} `json:"tracks"` } `json:"tracks"`
} }
@@ -182,11 +183,14 @@ type deezerPlaylistFull struct {
// SearchAll searches for tracks and artists on Deezer // SearchAll searches for tracks and artists on Deezer
// NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download // NOTE: ISRC is NOT fetched during search for performance - use GetTrackISRC when needed for download
func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) { func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit, artistLimit int) (*SearchAllResult, error) {
GoLog("[Deezer] SearchAll: query=%q, trackLimit=%d, artistLimit=%d\n", query, trackLimit, artistLimit)
cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit) cacheKey := fmt.Sprintf("deezer:all:%s:%d:%d", query, trackLimit, artistLimit)
c.cacheMu.RLock() c.cacheMu.RLock()
if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() { if entry, ok := c.searchCache[cacheKey]; ok && !entry.isExpired() {
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
GoLog("[Deezer] SearchAll: returning cached result\n")
return entry.data.(*SearchAllResult), nil return entry.data.(*SearchAllResult), nil
} }
c.cacheMu.RUnlock() c.cacheMu.RUnlock()
@@ -198,13 +202,28 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
// Search tracks - NO ISRC fetch for performance // Search tracks - NO ISRC fetch for performance
trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit) trackURL := fmt.Sprintf("%s/track?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), trackLimit)
GoLog("[Deezer] Fetching tracks from: %s\n", trackURL)
var trackResp struct { var trackResp struct {
Data []deezerTrack `json:"data"` Data []deezerTrack `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
} }
if err := c.getJSON(ctx, trackURL, &trackResp); err != nil { if err := c.getJSON(ctx, trackURL, &trackResp); err != nil {
GoLog("[Deezer] Track search failed: %v\n", err)
return nil, fmt.Errorf("deezer track search failed: %w", err) return nil, fmt.Errorf("deezer track search failed: %w", err)
} }
if trackResp.Error != nil {
GoLog("[Deezer] API error: type=%s, code=%d, message=%s\n", trackResp.Error.Type, trackResp.Error.Code, trackResp.Error.Message)
return nil, fmt.Errorf("deezer API error: %s (code %d)", trackResp.Error.Message, trackResp.Error.Code)
}
GoLog("[Deezer] Got %d tracks from API\n", len(trackResp.Data))
for _, track := range trackResp.Data { for _, track := range trackResp.Data {
// Convert directly without fetching ISRC - much faster // Convert directly without fetching ISRC - much faster
result.Tracks = append(result.Tracks, c.convertTrack(track)) result.Tracks = append(result.Tracks, c.convertTrack(track))
@@ -212,21 +231,37 @@ func (c *DeezerClient) SearchAll(ctx context.Context, query string, trackLimit,
// Search artists // Search artists
artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit) artistURL := fmt.Sprintf("%s/artist?q=%s&limit=%d", deezerSearchURL, url.QueryEscape(query), artistLimit)
GoLog("[Deezer] Fetching artists from: %s\n", artistURL)
var artistResp struct { var artistResp struct {
Data []deezerArtist `json:"data"` Data []deezerArtist `json:"data"`
Error *struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
} `json:"error"`
} }
if err := c.getJSON(ctx, artistURL, &artistResp); err == nil { if err := c.getJSON(ctx, artistURL, &artistResp); err == nil {
for _, artist := range artistResp.Data { if artistResp.Error != nil {
result.Artists = append(result.Artists, SearchArtistResult{ GoLog("[Deezer] Artist API error: type=%s, code=%d, message=%s\n", artistResp.Error.Type, artistResp.Error.Code, artistResp.Error.Message)
ID: fmt.Sprintf("deezer:%d", artist.ID), } else {
Name: artist.Name, GoLog("[Deezer] Got %d artists from API\n", len(artistResp.Data))
Images: c.getBestArtistImage(artist), for _, artist := range artistResp.Data {
Followers: artist.NbFan, result.Artists = append(result.Artists, SearchArtistResult{
Popularity: 0, ID: fmt.Sprintf("deezer:%d", artist.ID),
}) Name: artist.Name,
Images: c.getBestArtistImage(artist),
Followers: artist.NbFan,
Popularity: 0,
})
}
} }
} else {
GoLog("[Deezer] Artist search failed: %v\n", err)
} }
GoLog("[Deezer] SearchAll complete: %d tracks, %d artists\n", len(result.Tracks), len(result.Artists))
// Cache result // Cache result
c.cacheMu.Lock() c.cacheMu.Lock()
c.searchCache[cacheKey] = &cacheEntry{ c.searchCache[cacheKey] = &cacheEntry{
@@ -603,8 +638,6 @@ func (c *DeezerClient) GetTrackISRC(ctx context.Context, trackID string) (string
return fullTrack.ISRC, nil return fullTrack.ISRC, nil
} }
func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string { func (c *DeezerClient) getBestArtistImage(artist deezerArtist) string {
if artist.PictureXL != "" { if artist.PictureXL != "" {
return artist.PictureXL return artist.PictureXL
+7
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
+1 -1
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
+13 -5
View File
@@ -400,8 +400,9 @@ func ExtractLyrics(filePath string) (string, error) {
// AudioQuality represents audio quality info from a FLAC file // AudioQuality represents audio quality info from a FLAC file
type AudioQuality struct { type AudioQuality struct {
BitDepth int `json:"bit_depth"` BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`
TotalSamples int64 `json:"total_samples"`
} }
// GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block // GetAudioQuality reads bit depth and sample rate from a FLAC file's StreamInfo block
@@ -446,9 +447,17 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
// Parse bits per sample (5 bits) // Parse bits per sample (5 bits)
bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1 bitsPerSample := ((int(streamInfo[12]) & 0x01) << 4) | (int(streamInfo[13]) >> 4) + 1
// Parse total samples (36 bits: 4 bits from byte 13, all of bytes 14-17)
totalSamples := int64(streamInfo[13]&0x0F)<<32 |
int64(streamInfo[14])<<24 |
int64(streamInfo[15])<<16 |
int64(streamInfo[16])<<8 |
int64(streamInfo[17])
return AudioQuality{ return AudioQuality{
BitDepth: bitsPerSample, BitDepth: bitsPerSample,
SampleRate: sampleRate, SampleRate: sampleRate,
TotalSamples: totalSamples,
}, nil }, nil
} }
@@ -469,7 +478,6 @@ func GetAudioQuality(filePath string) (AudioQuality, error) {
return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)") return AudioQuality{}, fmt.Errorf("unsupported file format (not FLAC or M4A)")
} }
// ======================================== // ========================================
// M4A (MP4/AAC) Metadata Embedding // M4A (MP4/AAC) Metadata Embedding
// ======================================== // ========================================
+148 -1
View File
@@ -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 {
+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.8';
static const String buildNumber = '47'; static const String buildNumber = '50';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+4
View File
@@ -18,6 +18,7 @@ class AppSettings {
final bool hasSearchedBefore; // Hide helper text after first search final bool hasSearchedBefore; // Hide helper text after first search
final String folderOrganization; // none, artist, album, artist_album final String folderOrganization; // none, artist, album, artist_album
final String historyViewMode; // list, grid final String historyViewMode; // list, grid
final String historyFilterMode; // all, albums, singles
final bool askQualityBeforeDownload; // Show quality picker before each download final bool askQualityBeforeDownload; // Show quality picker before each download
final String spotifyClientId; // Custom Spotify client ID (empty = use default) final String spotifyClientId; // Custom Spotify client ID (empty = use default)
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default) final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
@@ -40,6 +41,7 @@ class AppSettings {
this.hasSearchedBefore = false, // Default: show helper text this.hasSearchedBefore = false, // Default: show helper text
this.folderOrganization = 'none', // Default: no folder organization this.folderOrganization = 'none', // Default: no folder organization
this.historyViewMode = 'grid', // Default: grid view this.historyViewMode = 'grid', // Default: grid view
this.historyFilterMode = 'all', // Default: show all
this.askQualityBeforeDownload = true, // Default: ask quality before download this.askQualityBeforeDownload = true, // Default: ask quality before download
this.spotifyClientId = '', // Default: use built-in credentials this.spotifyClientId = '', // Default: use built-in credentials
this.spotifyClientSecret = '', // Default: use built-in credentials this.spotifyClientSecret = '', // Default: use built-in credentials
@@ -63,6 +65,7 @@ class AppSettings {
bool? hasSearchedBefore, bool? hasSearchedBefore,
String? folderOrganization, String? folderOrganization,
String? historyViewMode, String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
String? spotifyClientId, String? spotifyClientId,
String? spotifyClientSecret, String? spotifyClientSecret,
@@ -85,6 +88,7 @@ class AppSettings {
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization, folderOrganization: folderOrganization ?? this.folderOrganization,
historyViewMode: historyViewMode ?? this.historyViewMode, historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
spotifyClientId: spotifyClientId ?? this.spotifyClientId, spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
+2
View File
@@ -21,6 +21,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none', folderOrganization: json['folderOrganization'] as String? ?? 'none',
historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
spotifyClientId: json['spotifyClientId'] as String? ?? '', spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '', spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
@@ -46,6 +47,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'hasSearchedBefore': instance.hasSearchedBefore, 'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization, 'folderOrganization': instance.folderOrganization,
'historyViewMode': instance.historyViewMode, 'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
'spotifyClientId': instance.spotifyClientId, 'spotifyClientId': instance.spotifyClientId,
'spotifyClientSecret': instance.spotifyClientSecret, 'spotifyClientSecret': instance.spotifyClientSecret,
+355 -185
View File
@@ -82,26 +82,27 @@ class DownloadHistoryItem {
'sampleRate': sampleRate, 'sampleRate': sampleRate,
}; };
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) => DownloadHistoryItem( factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) =>
id: json['id'] as String, DownloadHistoryItem(
trackName: json['trackName'] as String, id: json['id'] as String,
artistName: json['artistName'] as String, trackName: json['trackName'] as String,
albumName: json['albumName'] as String, artistName: json['artistName'] as String,
albumArtist: json['albumArtist'] as String?, albumName: json['albumName'] as String,
coverUrl: json['coverUrl'] as String?, albumArtist: json['albumArtist'] as String?,
filePath: json['filePath'] as String, coverUrl: json['coverUrl'] as String?,
service: json['service'] as String, filePath: json['filePath'] as String,
downloadedAt: DateTime.parse(json['downloadedAt'] as String), service: json['service'] as String,
isrc: json['isrc'] as String?, downloadedAt: DateTime.parse(json['downloadedAt'] as String),
spotifyId: json['spotifyId'] as String?, isrc: json['isrc'] as String?,
trackNumber: json['trackNumber'] as int?, spotifyId: json['spotifyId'] as String?,
discNumber: json['discNumber'] as int?, trackNumber: json['trackNumber'] as int?,
duration: json['duration'] as int?, discNumber: json['discNumber'] as int?,
releaseDate: json['releaseDate'] as String?, duration: json['duration'] as int?,
quality: json['quality'] as String?, releaseDate: json['releaseDate'] as String?,
bitDepth: json['bitDepth'] as int?, quality: json['quality'] as String?,
sampleRate: json['sampleRate'] as int?, bitDepth: json['bitDepth'] as int?,
); sampleRate: json['sampleRate'] as int?,
);
} }
// Download History State // Download History State
@@ -110,13 +111,14 @@ class DownloadHistoryState {
final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup final Set<String> _downloadedSpotifyIds; // Cache for O(1) lookup
DownloadHistoryState({this.items = const []}) DownloadHistoryState({this.items = const []})
: _downloadedSpotifyIds = items : _downloadedSpotifyIds = items
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty) .where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => item.spotifyId!) .map((item) => item.spotifyId!)
.toSet(); .toSet();
/// Check if a track has been downloaded (by Spotify ID) /// Check if a track has been downloaded (by Spotify ID)
bool isDownloaded(String spotifyId) => _downloadedSpotifyIds.contains(spotifyId); bool isDownloaded(String spotifyId) =>
_downloadedSpotifyIds.contains(spotifyId);
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) { DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
return DownloadHistoryState(items: items ?? this.items); return DownloadHistoryState(items: items ?? this.items);
@@ -150,7 +152,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final jsonStr = prefs.getString(_storageKey); final jsonStr = prefs.getString(_storageKey);
if (jsonStr != null && jsonStr.isNotEmpty) { if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr); final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>)).toList(); final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from storage'); _historyLog.i('Loaded ${items.length} items from storage');
} else { } else {
@@ -210,9 +214,10 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
} }
// Download History Provider // Download History Provider
final downloadHistoryProvider = NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>( final downloadHistoryProvider =
DownloadHistoryNotifier.new, NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
); DownloadHistoryNotifier.new,
);
class DownloadQueueState { class DownloadQueueState {
final List<DownloadItem> items; final List<DownloadItem> items;
@@ -261,10 +266,19 @@ class DownloadQueueState {
); );
} }
int get queuedCount => items.where((i) => i.status == DownloadStatus.queued || i.status == DownloadStatus.downloading).length; int get queuedCount => items
int get completedCount => items.where((i) => i.status == DownloadStatus.completed).length; .where(
int get failedCount => items.where((i) => i.status == DownloadStatus.failed).length; (i) =>
int get activeDownloadsCount => items.where((i) => i.status == DownloadStatus.downloading).length; i.status == DownloadStatus.queued ||
i.status == DownloadStatus.downloading,
)
.length;
int get completedCount =>
items.where((i) => i.status == DownloadStatus.completed).length;
int get failedCount =>
items.where((i) => i.status == DownloadStatus.failed).length;
int get activeDownloadsCount =>
items.where((i) => i.status == DownloadStatus.downloading).length;
} }
// Download Queue Notifier (Riverpod 3.x) // Download Queue Notifier (Riverpod 3.x)
@@ -272,7 +286,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer; Timer? _progressTimer;
int _downloadCount = 0; // Counter for connection cleanup int _downloadCount = 0; // Counter for connection cleanup
static const _cleanupInterval = 50; // Cleanup every 50 downloads static const _cleanupInterval = 50; // Cleanup every 50 downloads
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence static const _queueStorageKey =
'download_queue'; // Storage key for queue persistence
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
int _totalQueuedAtStart = 0; // Track total items when queue started int _totalQueuedAtStart = 0; // Track total items when queue started
int _completedInSession = 0; // Track completed downloads in current session int _completedInSession = 0; // Track completed downloads in current session
@@ -305,7 +320,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final jsonStr = prefs.getString(_queueStorageKey); final jsonStr = prefs.getString(_queueStorageKey);
if (jsonStr != null && jsonStr.isNotEmpty) { if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr); final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>)).toList(); final items = jsonList
.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>))
.toList();
// Reset downloading items to queued (they were interrupted) // Reset downloading items to queued (they were interrupted)
final restoredItems = items.map((item) { final restoredItems = items.map((item) {
@@ -316,9 +333,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}).toList(); }).toList();
// Only restore queued/downloading items (not completed/failed/skipped) // Only restore queued/downloading items (not completed/failed/skipped)
final pendingItems = restoredItems.where((item) => final pendingItems = restoredItems
item.status == DownloadStatus.queued .where((item) => item.status == DownloadStatus.queued)
).toList(); .toList();
if (pendingItems.isNotEmpty) { if (pendingItems.isNotEmpty) {
state = state.copyWith(items: pendingItems); state = state.copyWith(items: pendingItems);
@@ -345,10 +362,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Only persist queued and downloading items // Only persist queued and downloading items
final pendingItems = state.items.where((item) => final pendingItems = state.items
item.status == DownloadStatus.queued || .where(
item.status == DownloadStatus.downloading (item) =>
).toList(); item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading,
)
.toList();
if (pendingItems.isEmpty) { if (pendingItems.isEmpty) {
// Clear storage if no pending items // Clear storage if no pending items
@@ -367,7 +387,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
/// Start multi-progress polling for all downloads (sequential and parallel) /// Start multi-progress polling for all downloads (sequential and parallel)
void _startMultiProgressPolling() { void _startMultiProgressPolling() {
_progressTimer?.cancel(); _progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (
timer,
) async {
try { try {
final allProgress = await PlatformBridge.getAllDownloadProgress(); final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {}; final items = allProgress['items'] as Map<String, dynamic>? ?? {};
@@ -381,8 +403,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final itemProgress = entry.value as Map<String, dynamic>; final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0; final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 0; final bytesTotal = itemProgress['bytes_total'] as int? ?? 0;
final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; final speedMBps =
final isDownloading = itemProgress['is_downloading'] as bool? ?? false; (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0;
final isDownloading =
itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading'; final status = itemProgress['status'] as String? ?? 'downloading';
// Check if status is "finalizing" (embedding metadata) // Check if status is "finalizing" (embedding metadata)
@@ -391,7 +415,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0);
// Track finalizing item for notification // Track finalizing item for notification
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull; final currentItem = state.items
.where((i) => i.id == itemId)
.firstOrNull;
if (currentItem != null) { if (currentItem != null) {
hasFinalizingItem = true; hasFinalizingItem = true;
finalizingTrackName = currentItem.track.name; finalizingTrackName = currentItem.track.name;
@@ -401,16 +427,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
// Use progress from backend if available (handles both explicit progress and byte-based) // Use progress from backend if available (handles both explicit progress and byte-based)
final progressFromBackend = (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; final progressFromBackend =
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
if (isDownloading) { if (isDownloading) {
double percentage = 0.0; double percentage = 0.0;
if (bytesTotal > 0) { if (bytesTotal > 0) {
// Calculate from bytes if available for precision // Calculate from bytes if available for precision
percentage = bytesReceived / bytesTotal; percentage = bytesReceived / bytesTotal;
} else { } else {
// Fallback to backend-reported progress (e.g. for DASH segments) // Fallback to backend-reported progress (e.g. for DASH segments)
percentage = progressFromBackend; percentage = progressFromBackend;
} }
updateProgress(itemId, percentage, speedMBps: speedMBps); updateProgress(itemId, percentage, speedMBps: speedMBps);
@@ -419,9 +446,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final mbReceived = bytesReceived / (1024 * 1024); final mbReceived = bytesReceived / (1024 * 1024);
final mbTotal = bytesTotal / (1024 * 1024); final mbTotal = bytesTotal / (1024 * 1024);
if (bytesTotal > 0) { if (bytesTotal > 0) {
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s'); _log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
} else { } else {
_log.d('Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s'); _log.d(
'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
} }
} }
} }
@@ -443,7 +474,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
// Find downloading items (not finalizing) // Find downloading items (not finalizing)
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList(); final downloadingItems = state.items
.where((i) => i.status == DownloadStatus.downloading)
.toList();
if (downloadingItems.isNotEmpty) { if (downloadingItems.isNotEmpty) {
// Show single track name if only 1 download, otherwise show count // Show single track name if only 1 download, otherwise show count
final trackName = downloadingItems.length == 1 final trackName = downloadingItems.length == 1
@@ -459,7 +492,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (bytesTotal <= 0) { if (bytesTotal <= 0) {
// Fallback to percentage for DASH/unknown size // Fallback to percentage for DASH/unknown size
final progressPercent = (firstProgress['progress'] as num?)?.toDouble() ?? 0.0; final progressPercent =
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
notifProgress = (progressPercent * 100).toInt(); notifProgress = (progressPercent * 100).toInt();
notifTotal = 100; notifTotal = 100;
} }
@@ -509,7 +543,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Android: Use external storage Music folder // Android: Use external storage Music folder
final dir = await getExternalStorageDirectory(); final dir = await getExternalStorageDirectory();
if (dir != null) { if (dir != null) {
final musicDir = Directory('${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC'); final musicDir = Directory(
'${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
);
if (!await musicDir.exists()) { if (!await musicDir.exists()) {
await musicDir.create(recursive: true); await musicDir.create(recursive: true);
} }
@@ -588,7 +624,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
void updateSettings(AppSettings settings) { void updateSettings(AppSettings settings) {
state = state.copyWith( state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty ? settings.downloadDirectory : state.outputDir, outputDir: settings.downloadDirectory.isNotEmpty
? settings.downloadDirectory
: state.outputDir,
filenameFormat: settings.filenameFormat, filenameFormat: settings.filenameFormat,
audioQuality: settings.audioQuality, audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback, autoFallback: settings.autoFallback,
@@ -601,7 +639,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
updateSettings(settings); updateSettings(settings);
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; final id =
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
final item = DownloadItem( final item = DownloadItem(
id: id, id: id,
track: track, track: track,
@@ -621,13 +660,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return id; return id;
} }
void addMultipleToQueue(List<Track> tracks, String service, {String? qualityOverride}) { void addMultipleToQueue(
List<Track> tracks,
String service, {
String? qualityOverride,
}) {
// Sync settings before adding to queue // Sync settings before adding to queue
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
updateSettings(settings); updateSettings(settings);
final newItems = tracks.map((track) { final newItems = tracks.map((track) {
final id = '${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}'; final id =
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
return DownloadItem( return DownloadItem(
id: id, id: id,
track: track, track: track,
@@ -646,7 +690,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
void updateItemStatus(String id, DownloadStatus status, {double? progress, double? speedMBps, String? filePath, String? error, DownloadErrorType? errorType}) { void updateItemStatus(
String id,
DownloadStatus status, {
double? progress,
double? speedMBps,
String? filePath,
String? error,
DownloadErrorType? errorType,
}) {
final items = state.items.map((item) { final items = state.items.map((item) {
if (item.id == id) { if (item.id == id) {
return item.copyWith( return item.copyWith(
@@ -672,7 +724,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
void updateProgress(String id, double progress, {double? speedMBps}) { void updateProgress(String id, double progress, {double? speedMBps}) {
updateItemStatus(id, DownloadStatus.downloading, progress: progress, speedMBps: speedMBps); updateItemStatus(
id,
DownloadStatus.downloading,
progress: progress,
speedMBps: speedMBps,
);
} }
void cancelItem(String id) { void cancelItem(String id) {
@@ -680,11 +737,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
void clearCompleted() { void clearCompleted() {
final items = state.items.where((item) => final items = state.items
item.status != DownloadStatus.completed && .where(
item.status != DownloadStatus.failed && (item) =>
item.status != DownloadStatus.skipped item.status != DownloadStatus.completed &&
).toList(); item.status != DownloadStatus.failed &&
item.status != DownloadStatus.skipped,
)
.toList();
state = state.copyWith(items: items); state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue _saveQueueToStorage(); // Persist queue
@@ -734,7 +794,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
// Only retry if status is failed or skipped // Only retry if status is failed or skipped
if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) { if (item.status != DownloadStatus.failed &&
item.status != DownloadStatus.skipped) {
_log.w('retryItem: Item status is ${item.status}, not retrying'); _log.w('retryItem: Item status is ${item.status}, not retrying');
return; return;
} }
@@ -743,7 +804,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final items = state.items.map((i) { final items = state.items.map((i) {
if (i.id == id) { if (i.id == id) {
return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null); return i.copyWith(
status: DownloadStatus.queued,
progress: 0,
error: null,
);
} }
return i; return i;
}).toList(); }).toList();
@@ -774,7 +839,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (coverUrl != null && coverUrl.isNotEmpty) { if (coverUrl != null && coverUrl.isNotEmpty) {
try { try {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}'; final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_$uniqueId.jpg'; coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
// Download cover using HTTP // Download cover using HTTP
@@ -852,7 +918,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)'); _log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
} }
} catch (e) { } catch (e) {
_log.w('Failed to fetch lyrics for embedding: $e'); _log.w('Failed to fetch lyrics for embedding: $e');
} }
_log.d('Generating tags for FLAC: $metadata'); _log.d('Generating tags for FLAC: $metadata');
@@ -861,7 +927,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Note: FFmpegService.embedMetadata handles safe temp file creation // Note: FFmpegService.embedMetadata handles safe temp file creation
final result = await FFmpegService.embedMetadata( final result = await FFmpegService.embedMetadata(
flacPath: flacPath, flacPath: flacPath,
coverPath: coverPath != null && await File(coverPath).exists() ? coverPath : null, coverPath: coverPath != null && await File(coverPath).exists()
? coverPath
: null,
metadata: metadata, metadata: metadata,
); );
@@ -876,9 +944,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
final coverFile = File(coverPath); final coverFile = File(coverPath);
if (await coverFile.exists()) { if (await coverFile.exists()) {
// In Android 10+ scoped storage, we can't easily delete if we didn't create it // In Android 10+ scoped storage, we can't easily delete if we didn't create it
// in this session or if it's not in our app dir. // in this session or if it's not in our app dir.
// But coverPath is typically in temp dir now. // But coverPath is typically in temp dir now.
await coverFile.delete(); await coverFile.delete();
} }
} catch (_) {} } catch (_) {}
@@ -895,7 +963,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Starting queue processing...'); _log.i('Starting queue processing...');
// Track total items at start for notification // Track total items at start for notification
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length; _totalQueuedAtStart = state.items
.where((i) => i.status == DownloadStatus.queued)
.length;
_completedInSession = 0; _completedInSession = 0;
_failedInSession = 0; _failedInSession = 0;
@@ -968,7 +1038,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
// Show queue completion notification // Show queue completion notification
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart'); _log.i(
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
);
if (_totalQueuedAtStart > 0) { if (_totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete( await _notificationService.showQueueComplete(
completedCount: _completedInSession, completedCount: _completedInSession,
@@ -980,9 +1052,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
state = state.copyWith(isProcessing: false, currentDownload: null); state = state.copyWith(isProcessing: false, currentDownload: null);
// Check if there are new queued items (e.g., from retry) and restart if needed // Check if there are new queued items (e.g., from retry) and restart if needed
final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued); final hasQueuedItems = state.items.any(
(item) => item.status == DownloadStatus.queued,
);
if (hasQueuedItems) { if (hasQueuedItems) {
_log.i('Found queued items after processing finished, restarting queue...'); _log.i(
'Found queued items after processing finished, restarting queue...',
);
Future.microtask(() => _processQueue()); Future.microtask(() => _processQueue());
} }
} }
@@ -1006,18 +1082,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
(item) => item.status == DownloadStatus.queued, (item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem( orElse: () => DownloadItem(
id: '', id: '',
track: const Track(id: '', name: '', artistName: '', albumName: '', duration: 0), track: const Track(
id: '',
name: '',
artistName: '',
albumName: '',
duration: 0,
),
service: '', service: '',
createdAt: DateTime.now(), createdAt: DateTime.now(),
), ),
); );
if (nextItem.id.isEmpty) { if (nextItem.id.isEmpty) {
_log.d('No more items to process (checked ${currentItems.length} items)'); _log.d(
'No more items to process (checked ${currentItems.length} items)',
);
break; break;
} }
_log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})'); _log.d(
'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})',
);
await _downloadSingleItem(nextItem); await _downloadSingleItem(nextItem);
// Clear item progress after download completes // Clear item progress after download completes
@@ -1049,7 +1135,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
// Get queued items // Get queued items
final queuedItems = state.items.where((item) => item.status == DownloadStatus.queued).toList(); final queuedItems = state.items
.where((item) => item.status == DownloadStatus.queued)
.toList();
if (queuedItems.isEmpty && activeDownloads.isEmpty) { if (queuedItems.isEmpty && activeDownloads.isEmpty) {
_log.d('No more items to process'); _log.d('No more items to process');
@@ -1057,7 +1145,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
// Start new downloads up to max concurrent limit // Start new downloads up to max concurrent limit
while (activeDownloads.length < maxConcurrent && queuedItems.isNotEmpty && !state.isPaused) { while (activeDownloads.length < maxConcurrent &&
queuedItems.isNotEmpty &&
!state.isPaused) {
final item = queuedItems.removeAt(0); final item = queuedItems.removeAt(0);
// Mark as downloading immediately to prevent double-processing // Mark as downloading immediately to prevent double-processing
@@ -1071,7 +1161,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}); });
activeDownloads[item.id] = future; activeDownloads[item.id] = future;
_log.d('Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)'); _log.d(
'Started parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)',
);
} }
// Wait for at least one download to complete before checking for more // Wait for at least one download to complete before checking for more
@@ -1109,47 +1201,65 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Track trackToDownload = item.track; Track trackToDownload = item.track;
// Enrich metadata if ISRC or track number is missing (common from Search results) // Enrich metadata if ISRC or track number is missing (common from Search results)
// ISRC is critical for accurate track matching on streaming services // ISRC is critical for accurate track matching on streaming services
final needsEnrichment = trackToDownload.id.startsWith('deezer:') && final needsEnrichment =
(trackToDownload.isrc == null || trackToDownload.isrc!.isEmpty || trackToDownload.id.startsWith('deezer:') &&
trackToDownload.trackNumber == null || trackToDownload.trackNumber == 0); (trackToDownload.isrc == null ||
trackToDownload.isrc!.isEmpty ||
trackToDownload.trackNumber == null ||
trackToDownload.trackNumber == 0);
if (needsEnrichment) { if (needsEnrichment) {
try { try {
_log.d('Enriching incomplete metadata for Deezer track: ${trackToDownload.name}'); _log.d(
_log.d('Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}'); 'Enriching incomplete metadata for Deezer track: ${trackToDownload.name}',
);
_log.d(
'Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}',
);
final rawId = trackToDownload.id.split(':')[1]; final rawId = trackToDownload.id.split(':')[1];
_log.d('Fetching full metadata for Deezer ID: $rawId'); _log.d('Fetching full metadata for Deezer ID: $rawId');
final fullData = await PlatformBridge.getDeezerMetadata('track', rawId); final fullData = await PlatformBridge.getDeezerMetadata(
'track',
rawId,
);
_log.d('Got response keys: ${fullData.keys.toList()}'); _log.d('Got response keys: ${fullData.keys.toList()}');
if (fullData.containsKey('track')) { if (fullData.containsKey('track')) {
// Parse Go backend response (snake_case) to Track // Parse Go backend response (snake_case) to Track
final trackData = fullData['track']; final trackData = fullData['track'];
_log.d('Track data type: ${trackData.runtimeType}'); _log.d('Track data type: ${trackData.runtimeType}');
if (trackData is Map<String, dynamic>) { if (trackData is Map<String, dynamic>) {
final data = trackData; final data = trackData;
_log.d('Track data keys: ${data.keys.toList()}'); _log.d('Track data keys: ${data.keys.toList()}');
_log.d('ISRC from API: ${data['isrc']}'); _log.d('ISRC from API: ${data['isrc']}');
trackToDownload = Track( trackToDownload = Track(
id: (data['spotify_id'] as String?) ?? trackToDownload.id, id: (data['spotify_id'] as String?) ?? trackToDownload.id,
name: (data['name'] as String?) ?? trackToDownload.name, name: (data['name'] as String?) ?? trackToDownload.name,
artistName: (data['artists'] as String?) ?? trackToDownload.artistName, artistName:
albumName: (data['album_name'] as String?) ?? trackToDownload.albumName, (data['artists'] as String?) ?? trackToDownload.artistName,
albumArtist: data['album_artist'] as String?, albumName:
coverUrl: data['images'] as String?, (data['album_name'] as String?) ??
// duration_ms from Go is in milliseconds, Track.duration is in seconds trackToDownload.albumName,
duration: ((data['duration_ms'] as int?) ?? (trackToDownload.duration * 1000)) ~/ 1000, albumArtist: data['album_artist'] as String?,
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc, coverUrl: data['images'] as String?,
trackNumber: data['track_number'] as int?, // duration_ms from Go is in milliseconds, Track.duration is in seconds
discNumber: data['disc_number'] as int?, duration:
releaseDate: data['release_date'] as String?, ((data['duration_ms'] as int?) ??
deezerId: rawId, (trackToDownload.duration * 1000)) ~/
availability: trackToDownload.availability, 1000,
); isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
_log.d('Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}'); trackNumber: data['track_number'] as int?,
} else { discNumber: data['disc_number'] as int?,
_log.w('Unexpected track data type: ${trackData.runtimeType}'); releaseDate: data['release_date'] as String?,
} deezerId: rawId,
availability: trackToDownload.availability,
);
_log.d(
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}',
);
} else {
_log.w('Unexpected track data type: ${trackData.runtimeType}');
}
} else { } else {
_log.w('Response does not contain track key'); _log.w('Response does not contain track key');
} }
@@ -1159,7 +1269,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
final outputDir = await _buildOutputDir(trackToDownload, settings.folderOrganization); // Log cover URL for debugging CSV import issues
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
final outputDir = await _buildOutputDir(
trackToDownload,
settings.folderOrganization,
);
// Use quality override if set, otherwise use default from settings // Use quality override if set, otherwise use default from settings
final quality = item.qualityOverride ?? state.audioQuality; final quality = item.qualityOverride ?? state.audioQuality;
@@ -1168,7 +1284,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (state.autoFallback) { if (state.autoFallback) {
_log.d('Using auto-fallback mode'); _log.d('Using auto-fallback mode');
_log.d('Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}'); _log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
);
_log.d('Output dir: $outputDir'); _log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback( result = await PlatformBridge.downloadWithFallback(
isrc: trackToDownload.isrc ?? '', isrc: trackToDownload.isrc ?? '',
@@ -1186,7 +1304,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
releaseDate: trackToDownload.releaseDate, releaseDate: trackToDownload.releaseDate,
preferredService: item.service, preferredService: item.service,
itemId: item.id, // Pass item ID for progress tracking itemId: item.id, // Pass item ID for progress tracking
durationMs: trackToDownload.duration, // Duration in ms for verification durationMs:
trackToDownload.duration, // Duration in ms for verification
); );
} else { } else {
result = await PlatformBridge.downloadTrack( result = await PlatformBridge.downloadTrack(
@@ -1205,14 +1324,18 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
discNumber: trackToDownload.discNumber ?? 1, discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate, releaseDate: trackToDownload.releaseDate,
itemId: item.id, // Pass item ID for progress tracking itemId: item.id, // Pass item ID for progress tracking
durationMs: trackToDownload.duration, // Duration in ms for verification durationMs:
trackToDownload.duration, // Duration in ms for verification
); );
} }
_log.d('Result: $result'); _log.d('Result: $result');
// Check if item was cancelled while downloading // Check if item was cancelled while downloading
final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item); final currentItem = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (currentItem.status == DownloadStatus.skipped) { if (currentItem.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping result processing'); _log.i('Download was cancelled, skipping result processing');
// Delete the downloaded file if it exists // Delete the downloaded file if it exists
@@ -1243,7 +1366,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (actualBitDepth != null && actualBitDepth > 0) { if (actualBitDepth != null && actualBitDepth > 0) {
// Format: "24-bit/96kHz" or "16-bit/44.1kHz" // Format: "24-bit/96kHz" or "16-bit/44.1kHz"
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0 final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
? (actualSampleRate / 1000).toStringAsFixed(actualSampleRate % 1000 == 0 ? 0 : 1) ? (actualSampleRate / 1000).toStringAsFixed(
actualSampleRate % 1000 == 0 ? 0 : 1,
)
: '?'; : '?';
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz'; actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
_log.i('Actual quality: $actualQuality'); _log.i('Actual quality: $actualQuality');
@@ -1252,20 +1377,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// M4A files from Tidal DASH streams - try to convert to FLAC // M4A files from Tidal DASH streams - try to convert to FLAC
// M4A files from Tidal DASH streams - try to convert to FLAC // M4A files from Tidal DASH streams - try to convert to FLAC
if (filePath != null && filePath.endsWith('.m4a')) { if (filePath != null && filePath.endsWith('.m4a')) {
_log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...'); _log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try { try {
final file = File(filePath); final file = File(filePath);
if (!await file.exists()) { if (!await file.exists()) {
_log.e('File does not exist at path: $filePath'); _log.e('File does not exist at path: $filePath');
} else { } else {
final length = await file.length(); final length = await file.length();
_log.i('File size before conversion: ${length / 1024} KB'); _log.i('File size before conversion: ${length / 1024} KB');
if (length < 1024) { if (length < 1024) {
_log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.'); _log.w(
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
);
} else { } else {
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95); updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(filePath); final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) { if (flacPath != null) {
@@ -1278,36 +1411,49 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Update track with actual metadata from backend result (if available) // Update track with actual metadata from backend result (if available)
// This creates the most accurate metadata possible (from the service itself) // This creates the most accurate metadata possible (from the service itself)
Track finalTrack = trackToDownload; Track finalTrack = trackToDownload;
if (result.containsKey('track_number') || result.containsKey('release_date')) { if (result.containsKey('track_number') ||
_log.d('Using metadata from backend response for embedding'); result.containsKey('release_date')) {
final backendTrackNum = result['track_number'] as int?; _log.d(
final backendDiscNum = result['disc_number'] as int?; 'Using metadata from backend response for embedding',
final backendYear = result['release_date'] as String?; );
final backendAlbum = result['album'] as String?; final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?;
final backendYear = result['release_date'] as String?;
final backendAlbum = result['album'] as String?;
_log.d('Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear'); _log.d(
'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear',
);
// Create updated track object with safety check for 0/null // Create updated track object with safety check for 0/null
final newTrackNumber = (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : trackToDownload.trackNumber; final newTrackNumber =
final newDiscNumber = (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : trackToDownload.discNumber; (backendTrackNum != null && backendTrackNum > 0)
? backendTrackNum
: trackToDownload.trackNumber;
final newDiscNumber =
(backendDiscNum != null && backendDiscNum > 0)
? backendDiscNum
: trackToDownload.discNumber;
_log.d('Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber'); _log.d(
'Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber',
);
finalTrack = Track( finalTrack = Track(
id: trackToDownload.id, id: trackToDownload.id,
name: trackToDownload.name, name: trackToDownload.name,
artistName: trackToDownload.artistName, artistName: trackToDownload.artistName,
albumName: backendAlbum ?? trackToDownload.albumName, albumName: backendAlbum ?? trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist, albumArtist: trackToDownload.albumArtist,
coverUrl: trackToDownload.coverUrl, coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration, duration: trackToDownload.duration,
isrc: trackToDownload.isrc, isrc: trackToDownload.isrc,
trackNumber: newTrackNumber, trackNumber: newTrackNumber,
discNumber: newDiscNumber, discNumber: newDiscNumber,
releaseDate: backendYear ?? trackToDownload.releaseDate, releaseDate: backendYear ?? trackToDownload.releaseDate,
deezerId: trackToDownload.deezerId, deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability, availability: trackToDownload.availability,
); );
} }
// Use enriched/updated track for metadata embedding // Use enriched/updated track for metadata embedding
@@ -1328,7 +1474,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
// Check again if cancelled before updating status and adding to history // Check again if cancelled before updating status and adding to history
final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item); final itemAfterDownload = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (itemAfterDownload.status == DownloadStatus.skipped) { if (itemAfterDownload.status == DownloadStatus.skipped) {
_log.i('Download was cancelled during finalization, cleaning up'); _log.i('Download was cancelled during finalization, cleaning up');
// Delete the downloaded file // Delete the downloaded file
@@ -1365,7 +1514,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
if (filePath != null) { if (filePath != null) {
// Extract updated metadata from backend result if available // Extract metadata from backend result (most accurate source)
final backendTitle = result['title'] as String?; final backendTitle = result['title'] as String?;
final backendArtist = result['artist'] as String?; final backendArtist = result['artist'] as String?;
final backendAlbum = result['album'] as String?; final backendAlbum = result['album'] as String?;
@@ -1376,29 +1525,47 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendSampleRate = result['actual_sample_rate'] as int?; final backendSampleRate = result['actual_sample_rate'] as int?;
final backendISRC = result['isrc'] as String?; final backendISRC = result['isrc'] as String?;
ref.read(downloadHistoryProvider.notifier).addToHistory( // Log cover URL for debugging
DownloadHistoryItem( _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
id: item.id,
trackName: (backendTitle != null && backendTitle.isNotEmpty) ? backendTitle : item.track.name, ref
artistName: (backendArtist != null && backendArtist.isNotEmpty) ? backendArtist : item.track.artistName, .read(downloadHistoryProvider.notifier)
albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : item.track.albumName, .addToHistory(
albumArtist: item.track.albumArtist, DownloadHistoryItem(
coverUrl: item.track.coverUrl, id: item.id,
filePath: filePath, trackName: (backendTitle != null && backendTitle.isNotEmpty)
service: result['service'] as String? ?? item.service, ? backendTitle
downloadedAt: DateTime.now(), : trackToDownload.name,
// Additional metadata artistName: (backendArtist != null && backendArtist.isNotEmpty)
isrc: (backendISRC != null && backendISRC.isNotEmpty) ? backendISRC : item.track.isrc, ? backendArtist
spotifyId: item.track.id, : trackToDownload.artistName,
trackNumber: (backendTrackNum != null && backendTrackNum > 0) ? backendTrackNum : item.track.trackNumber, albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
discNumber: (backendDiscNum != null && backendDiscNum > 0) ? backendDiscNum : item.track.discNumber, ? backendAlbum
duration: item.track.duration, : trackToDownload.albumName,
releaseDate: (backendYear != null && backendYear.isNotEmpty) ? backendYear : item.track.releaseDate, albumArtist: trackToDownload.albumArtist,
quality: actualQuality, coverUrl: trackToDownload.coverUrl,
bitDepth: backendBitDepth, filePath: filePath,
sampleRate: backendSampleRate, service: result['service'] as String? ?? item.service,
), downloadedAt: DateTime.now(),
); isrc: (backendISRC != null && backendISRC.isNotEmpty)
? backendISRC
: trackToDownload.isrc,
spotifyId: trackToDownload.id,
trackNumber: (backendTrackNum != null && backendTrackNum > 0)
? backendTrackNum
: trackToDownload.trackNumber,
discNumber: (backendDiscNum != null && backendDiscNum > 0)
? backendDiscNum
: trackToDownload.discNumber,
duration: trackToDownload.duration,
releaseDate: (backendYear != null && backendYear.isNotEmpty)
? backendYear
: trackToDownload.releaseDate,
quality: actualQuality,
bitDepth: backendBitDepth,
sampleRate: backendSampleRate,
),
);
// Auto-remove completed item from queue (it's now in history) // Auto-remove completed item from queue (it's now in history)
removeItem(item.id); removeItem(item.id);
@@ -1436,7 +1603,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Increment download counter and cleanup connections periodically // Increment download counter and cleanup connections periodically
_downloadCount++; _downloadCount++;
if (_downloadCount % _cleanupInterval == 0) { if (_downloadCount % _cleanupInterval == 0) {
_log.d('Cleaning up idle connections (after $_downloadCount downloads)...'); _log.d(
'Cleaning up idle connections (after $_downloadCount downloads)...',
);
try { try {
await PlatformBridge.cleanupConnections(); await PlatformBridge.cleanupConnections();
} catch (e) { } catch (e) {
@@ -1452,8 +1621,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
// Check for specific Deezer fallback error // Check for specific Deezer fallback error
if (errorMsg.contains('could not find Deezer equivalent') || if (errorMsg.contains('could not find Deezer equivalent') ||
errorMsg.contains('track not found on Deezer')) { errorMsg.contains('track not found on Deezer')) {
errorMsg = 'Track not found on Deezer (Metadata Unavailable)'; errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
errorType = DownloadErrorType.notFound; errorType = DownloadErrorType.notFound;
} }
updateItemStatus( updateItemStatus(
@@ -1467,6 +1636,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
final downloadQueueProvider = NotifierProvider<DownloadQueueNotifier, DownloadQueueState>( final downloadQueueProvider =
DownloadQueueNotifier.new, NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
); DownloadQueueNotifier.new,
);
+5
View File
@@ -148,6 +148,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setHistoryFilterMode(String mode) {
state = state.copyWith(historyFilterMode: mode);
_saveSettings();
}
void setAskQualityBeforeDownload(bool enabled) { void setAskQualityBeforeDownload(bool enabled) {
state = state.copyWith(askQualityBeforeDownload: enabled); state = state.copyWith(askQualityBeforeDownload: enabled);
_saveSettings(); _saveSettings();
+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);
} }
} }
+573
View File
@@ -0,0 +1,573 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
/// Screen to display downloaded tracks from a specific album
class DownloadedAlbumScreen extends ConsumerStatefulWidget {
final String albumName;
final String artistName;
final String? coverUrl;
const DownloadedAlbumScreen({
super.key,
required this.albumName,
required this.artistName,
this.coverUrl,
});
@override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
// Multi-select state
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
return allItems.where((item) {
final itemKey = '${item.albumName}|${item.albumArtist ?? item.artistName}';
final albumKey = '${widget.albumName}|${widget.artistName}';
return itemKey == albumKey;
}).toList()
..sort((a, b) {
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
return a.trackName.compareTo(b.trackName);
});
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
_isSelectionMode = true;
_selectedIds.add(itemId);
});
}
void _exitSelectionMode() {
setState(() {
_isSelectionMode = false;
_selectedIds.clear();
});
}
void _toggleSelection(String itemId) {
setState(() {
if (_selectedIds.contains(itemId)) {
_selectedIds.remove(itemId);
if (_selectedIds.isEmpty) {
_isSelectionMode = false;
}
} else {
_selectedIds.add(itemId);
}
});
}
void _selectAll(List<DownloadHistoryItem> tracks) {
setState(() {
_selectedIds.addAll(tracks.map((e) => e.id));
});
}
Future<void> _deleteSelected(List<DownloadHistoryItem> currentTracks) async {
final count = _selectedIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Selected'),
content: Text('Delete $count ${count == 1 ? 'track' : 'tracks'} from this album?\n\nThis will also delete the files from storage.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final idsToDelete = _selectedIds.toList();
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
if (item != null) {
try {
final file = File(item.filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
historyNotifier.removeFromHistory(id);
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Deleted $deletedCount ${deletedCount == 1 ? 'track' : 'tracks'}')),
);
}
}
}
Future<void> _openFile(String filePath) async {
try {
await OpenFilex.open(filePath);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Cannot open file: $e')),
);
}
}
}
void _navigateToMetadataScreen(DownloadHistoryItem item) {
Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 250),
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
));
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
// Watch history and get tracks for this album (reactive!)
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final tracks = _getAlbumTracks(allHistoryItems);
// Auto-pop if album has less than 2 tracks (no longer an "album")
if (tracks.length < 2) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) Navigator.pop(context);
});
return const SizedBox.shrink();
}
// Clean up selected IDs that no longer exist
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) setState(() => _isSelectionMode = false);
});
}
return PopScope(
canPop: !_isSelectionMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isSelectionMode) {
_exitSelectionMode();
}
},
child: Scaffold(
body: Stack(
children: [
CustomScrollView(
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
],
),
// Bottom Selection Action Bar
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
),
],
),
),
);
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
return SliverAppBar(
expandedHeight: 280,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (widget.coverUrl != null)
CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
memCacheWidth: 600,
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: 140,
height: 140,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: widget.coverUrl != null
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 48, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 4),
Text(
widget.artistName,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 4),
Text('${tracks.length} downloaded', style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(width: 8),
if (_getCommonQuality(tracks) != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
style: TextStyle(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
),
],
),
],
),
),
),
),
);
}
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
if (tracks.isEmpty) return null;
final firstQuality = tracks.first.quality;
if (firstQuality == null) return null;
for (final track in tracks) {
if (track.quality != firstQuality) return null;
}
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
child: Row(
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text('Tracks', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
const Spacer(),
if (!_isSelectionMode)
TextButton.icon(
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
icon: const Icon(Icons.checklist, size: 18),
label: const Text('Select'),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
),
],
),
),
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
final isSelected = _selectedIds.contains(track.id);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card(
elevation: 0,
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_isSelectionMode) ...[
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
: null,
),
const SizedBox(width: 12),
],
SizedBox(
width: 24,
child: Text(
track.trackNumber?.toString() ?? '-',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
],
),
title: Text(
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Text(
track.artistName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: _isSelectionMode ? null : IconButton(
onPressed: () => _openFile(track.filePath),
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
),
),
),
),
);
}
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(28)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, -4),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, bottomPadding > 0 ? 8 : 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
Row(
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$selectedCount selected',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? 'All tracks selected' : 'Tap tracks to select',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
TextButton.icon(
onPressed: () {
if (allSelected) {
_exitSelectionMode();
} else {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? 'Deselect' : 'Select All'),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}'
: 'Select tracks to delete',
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
),
),
],
),
),
),
);
}
}
+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);
+979 -390
View File
File diff suppressed because it is too large Load Diff
+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),
),
],
);
}
}
+473 -106
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,6 +136,270 @@ class AppearanceSettingsPage extends ConsumerWidget {
} }
} }
/// A simplified preview of how the app looks with current settings
class _ThemePreviewCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
return RepaintBoundary(
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme
.surfaceContainerHighest, // Background similar to reference
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
// Decorative background blobs
Positioned(
top: -50,
right: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
),
),
),
// Foreground "fake UI"
Center(
child: Container(
width: 260,
height: 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12, // Reduced from 20 for performance
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
// Fake Album Art
Container(
width: 108,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
),
),
const SizedBox(width: 16),
// Fake Text Info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 8),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 24),
Row(
children: [
Icon(
Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
),
],
),
],
),
),
],
),
),
),
// Label badge
Positioned(
bottom: 12,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isDark ? 'Dark Mode' : 'Light Mode',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}
class _ColorPalettePicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPalettePicker({
required this.currentColor,
required this.onColorSelected,
});
static const _colors = [
Color(0xFF1DB954),
Color(0xFF6750A4),
Color(0xFF0061A4),
Color(0xFF006E1C),
Color(0xFFBA1A1A),
Color(0xFF984061),
Color(0xFF7D5260),
Color(0xFF006874),
];
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: () => onColorSelected(color),
child: _ColorPaletteItem(color: color, isSelected: isSelected),
),
);
}).toList(),
),
);
}
}
class _ColorPaletteItem extends StatelessWidget {
final Color color;
final bool isSelected;
const _ColorPaletteItem({required this.color, required this.isSelected});
@override
Widget build(BuildContext context) {
final scheme = ColorScheme.fromSeed(
seedColor: color,
brightness: Theme.of(context).brightness,
);
final size = 64.0;
return Stack(
children: [
Container(
width: size,
height: size,
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
Expanded(
child: Row(
children: [
Expanded(child: Container(color: scheme.primaryContainer)),
Expanded(child: Container(color: scheme.tertiaryContainer)),
],
),
),
Expanded(
child: Row(
children: [
Expanded(
child: Container(color: scheme.secondaryContainer),
),
Expanded(child: Container(color: scheme.surfaceContainer)),
],
),
),
],
),
),
if (isSelected)
Positioned.fill(
child: Center(
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Icon(Icons.check, size: 16, color: scheme.primary),
),
),
),
],
);
}
}
/// Optimized app bar title with animation /// Optimized app bar title with animation
class _AppBarTitle extends StatelessWidget { class _AppBarTitle extends StatelessWidget {
final String title; final String title;
@@ -110,7 +414,9 @@ class _AppBarTitle extends StatelessWidget {
builder: (context, constraints) { builder: (context, constraints) {
final maxHeight = 120 + topPadding; final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding; final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final expandRatio =
((constraints.maxHeight - minHeight) / (maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 final leftPadding = 56 - (32 * expandRatio); // 56 -> 24
return FlexibleSpaceBar( return FlexibleSpaceBar(
expandedTitleScale: 1.0, expandedTitleScale: 1.0,
@@ -132,19 +438,39 @@ class _AppBarTitle extends StatelessWidget {
class _ThemeModeSelector extends StatelessWidget { class _ThemeModeSelector extends StatelessWidget {
final ThemeMode currentMode; final ThemeMode currentMode;
final ValueChanged<ThemeMode> onChanged; final ValueChanged<ThemeMode> onChanged;
const _ThemeModeSelector({required this.currentMode, required this.onChanged}); const _ThemeModeSelector({
required this.currentMode,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row(children: [ child: Row(
_ThemeModeChip(icon: Icons.brightness_auto, label: 'System', isSelected: currentMode == ThemeMode.system, onTap: () => onChanged(ThemeMode.system)), children: [
const SizedBox(width: 8), _ThemeModeChip(
_ThemeModeChip(icon: Icons.light_mode, label: 'Light', isSelected: currentMode == ThemeMode.light, onTap: () => onChanged(ThemeMode.light)), icon: Icons.brightness_auto,
const SizedBox(width: 8), label: 'System',
_ThemeModeChip(icon: Icons.dark_mode, label: 'Dark', isSelected: currentMode == ThemeMode.dark, onTap: () => onChanged(ThemeMode.dark)), isSelected: currentMode == ThemeMode.system,
]), onTap: () => onChanged(ThemeMode.system),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.light_mode,
label: 'Light',
isSelected: currentMode == ThemeMode.light,
onTap: () => onChanged(ThemeMode.light),
),
const SizedBox(width: 8),
_ThemeModeChip(
icon: Icons.dark_mode,
label: 'Dark',
isSelected: currentMode == ThemeMode.dark,
onTap: () => onChanged(ThemeMode.dark),
),
],
),
); );
} }
} }
@@ -154,7 +480,12 @@ class _ThemeModeChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
const _ThemeModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); const _ThemeModeChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -165,8 +496,14 @@ class _ThemeModeChip extends StatelessWidget {
// Card uses: dark = white 8% overlay, light = surfaceContainerHighest // Card uses: dark = white 8% overlay, light = surfaceContainerHighest
// So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card) // So chips use: dark = white 5% overlay (darker), light = black 5% overlay (darker than card)
final unselectedColor = isDark final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) ? Color.alphaBlend(
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest); Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded( return Expanded(
child: Container( child: Container(
@@ -174,7 +511,10 @@ class _ThemeModeChip extends StatelessWidget {
color: isSelected ? colorScheme.primaryContainer : unselectedColor, color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1) ? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null, : null,
), ),
child: Material( child: Material(
@@ -185,13 +525,29 @@ class _ThemeModeChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [ child: Column(
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), children: [
const SizedBox(height: 6), Icon(
Text(label, style: TextStyle(fontSize: 12, icon,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), ? colorScheme.onPrimaryContainer
]), : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
), ),
), ),
), ),
@@ -200,49 +556,13 @@ class _ThemeModeChip extends StatelessWidget {
} }
} }
class _ColorPicker extends StatelessWidget {
final int currentColor;
final ValueChanged<Color> onColorSelected;
const _ColorPicker({required this.currentColor, required this.onColorSelected});
static const _colors = [
Color(0xFF1DB954), Color(0xFF6750A4), Color(0xFF0061A4), Color(0xFF006E1C),
Color(0xFFBA1A1A), Color(0xFF984061), Color(0xFF7D5260), Color(0xFF006874), Color(0xFFFF6F00),
];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Accent Color', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
const SizedBox(height: 12),
Wrap(spacing: 12, runSpacing: 12, children: _colors.map((color) {
final isSelected = color.toARGB32() == currentColor;
return GestureDetector(
onTap: () => onColorSelected(color),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 44, height: 44,
decoration: BoxDecoration(
color: color, shape: BoxShape.circle,
border: isSelected ? Border.all(color: colorScheme.onSurface, width: 3) : null,
boxShadow: isSelected ? [BoxShadow(color: color.withValues(alpha: 0.4), blurRadius: 8, spreadRadius: 2)] : null,
),
child: isSelected ? const Icon(Icons.check, color: Colors.white, size: 20) : null,
),
);
}).toList()),
]),
);
}
}
class _HistoryViewSelector extends StatelessWidget { class _HistoryViewSelector extends StatelessWidget {
final String currentMode; final String currentMode;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _HistoryViewSelector({required this.currentMode, required this.onChanged}); const _HistoryViewSelector({
required this.currentMode,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -254,13 +574,30 @@ class _HistoryViewSelector extends StatelessWidget {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 8, bottom: 8), padding: const EdgeInsets.only(left: 8, bottom: 8),
child: Text('History View', style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), child: Text(
'History View',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
Row(
children: [
_ViewModeChip(
icon: Icons.view_list,
label: 'List',
isSelected: currentMode == 'list',
onTap: () => onChanged('list'),
),
const SizedBox(width: 8),
_ViewModeChip(
icon: Icons.grid_view,
label: 'Grid',
isSelected: currentMode == 'grid',
onTap: () => onChanged('grid'),
),
],
), ),
Row(children: [
_ViewModeChip(icon: Icons.view_list, label: 'List', isSelected: currentMode == 'list', onTap: () => onChanged('list')),
const SizedBox(width: 8),
_ViewModeChip(icon: Icons.grid_view, label: 'Grid', isSelected: currentMode == 'grid', onTap: () => onChanged('grid')),
]),
], ],
), ),
); );
@@ -272,7 +609,12 @@ class _ViewModeChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
const _ViewModeChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); const _ViewModeChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -281,8 +623,14 @@ class _ViewModeChip extends StatelessWidget {
// Unselected chips need contrast with card background // Unselected chips need contrast with card background
final unselectedColor = isDark final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) ? Color.alphaBlend(
: Color.alphaBlend(Colors.black.withValues(alpha: 0.05), colorScheme.surfaceContainerHighest); Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: Color.alphaBlend(
Colors.black.withValues(alpha: 0.05),
colorScheme.surfaceContainerHighest,
);
return Expanded( return Expanded(
child: Container( child: Container(
@@ -290,7 +638,10 @@ class _ViewModeChip extends StatelessWidget {
color: isSelected ? colorScheme.primaryContainer : unselectedColor, color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: !isDark && !isSelected border: !isDark && !isSelected
? Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5), width: 1) ? Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
width: 1,
)
: null, : null,
), ),
child: Material( child: Material(
@@ -301,13 +652,29 @@ class _ViewModeChip extends StatelessWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
child: Column(children: [ child: Column(
Icon(icon, color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant), children: [
const SizedBox(height: 6), Icon(
Text(label, style: TextStyle(fontSize: 12, icon,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant)), ? colorScheme.onPrimaryContainer
]), : colorScheme.onSurfaceVariant,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
],
),
), ),
), ),
), ),
+439 -133
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,9 @@ 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 +370,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 +380,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 +403,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 +418,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 +434,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 +472,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 +491,61 @@ 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 +558,39 @@ class DownloadSettingsPage extends ConsumerWidget {
class _ServiceSelector extends StatelessWidget { class _ServiceSelector extends StatelessWidget {
final String currentService; final String currentService;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _ServiceSelector({required this.currentService, required this.onChanged}); const _ServiceSelector({
required this.currentService,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row(children: [ child: Row(
_ServiceChip(icon: Icons.music_note, label: 'Tidal', isSelected: currentService == 'tidal', onTap: () => onChanged('tidal')), children: [
const SizedBox(width: 8), _ServiceChip(
_ServiceChip(icon: Icons.album, label: 'Qobuz', isSelected: currentService == 'qobuz', onTap: () => onChanged('qobuz')), icon: Icons.music_note,
const SizedBox(width: 8), label: 'Tidal',
_ServiceChip(icon: Icons.shopping_bag, label: 'Amazon', isSelected: currentService == 'amazon', onTap: () => onChanged('amazon')), isSelected: currentService == 'tidal',
]), onTap: () => onChanged('tidal'),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: currentService == 'qobuz',
onTap: () => onChanged('qobuz'),
),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag,
label: 'Amazon',
isSelected: currentService == 'amazon',
onTap: () => onChanged('amazon'),
),
],
),
); );
} }
} }
@@ -344,7 +600,12 @@ class _ServiceChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
const _ServiceChip({required this.icon, required this.label, required this.isSelected, required this.onTap}); const _ServiceChip({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -352,7 +613,10 @@ class _ServiceChip extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface) ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
colorScheme.surface,
)
: colorScheme.surfaceContainerHigh; : colorScheme.surfaceContainerHigh;
return Expanded( return Expanded(
@@ -364,13 +628,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 +664,13 @@ class _QualityOption extends StatelessWidget {
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
final bool showDivider; final bool showDivider;
const _QualityOption({required this.title, required this.subtitle, required this.isSelected, required this.onTap, this.showDivider = true}); const _QualityOption({
required this.title,
required this.subtitle,
required this.isSelected,
required this.onTap,
this.showDivider = true,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -404,7 +690,12 @@ class _QualityOption extends StatelessWidget {
children: [ children: [
Text(title, style: Theme.of(context).textTheme.bodyLarge), Text(title, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)), Text(
subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
], ],
), ),
), ),
@@ -434,7 +725,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 +744,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.8+50
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.8+50
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0