feat: v3.1.2 - MP3 option, dominant color headers, sticky titles, disc separation

Added:
- MP3 quality option with FLAC-to-MP3 conversion (320kbps)
- Dominant color header backgrounds on detail screens
- Spotify-style sticky title on scroll (album, playlist, artist screens)
- Disc separation for multi-disc albums
- Album grouping in recent downloads
- 50% screen width cover art

Changed:
- Improved FFmpeg FLAC-to-MP3 conversion workflow
- AppBar uses theme surface color when collapsed

Fixed:
- Empty catch blocks with proper comments
- Russian plural forms (ICU syntax)

Dependencies:
- Added palette_generator ^0.3.3+4
This commit is contained in:
zarzet
2026-01-18 12:18:03 +07:00
parent 1c445e91d9
commit da574f895c
33 changed files with 1838 additions and 2540 deletions
+78
View File
@@ -1,5 +1,83 @@
# Changelog
## [3.1.2] - 2026-01-18
### Added
- **MP3 Quality Option**: Optional MP3 download format with FLAC-to-MP3 conversion
- New "Enable MP3 Option" toggle in Settings > Download > Audio Quality
- When enabled, MP3 (320kbps) appears as a quality option alongside FLAC options
- Available in both the quality picker dialog and default quality settings
- Works with all services (Tidal, Qobuz, Amazon) and extensions
- **MP3 Metadata Embedding**: Full metadata support for MP3 files
- Cover art embedded using ID3v2 tags
- Synced lyrics embedded (fetched from lrclib.net)
- All metadata preserved: title, artist, album, album artist, track/disc number, date, ISRC
- Automatic tag conversion from Vorbis comments (FLAC) to ID3v2 (MP3)
- **Dominant Color Header**: Album, Playlist, Downloaded Album, and Track Metadata screens now feature dynamic header backgrounds
- Extracts dominant color from cover art using `palette_generator`
- Creates a gradient from dominant color to theme surface color
- Smooth 500ms color transition animation
- **Larger Cover Art**: Cover images on detail screens are now 50% of screen width (previously 140px fixed)
- More prominent album artwork display
- Larger shadow and rounded corners (20px radius)
- Higher resolution cover caching
- **Spotify-style Sticky Title**: Title appears in AppBar when scrolling past the info card
- Smooth fade-in animation (200ms) when scrolling down
- Title hidden when header is expanded (shows in info card instead)
- AppBar uses theme color (surface) for clean, native look
- Works on Album, Playlist, Downloaded Album, Track Metadata, and Artist screens
- **Artist Name in Album Screen**: Album info card now displays artist name below album title
- Extracted from first track's artist metadata
- Styled with `onSurfaceVariant` color for visual hierarchy
- **Disc Separation for Multi-Disc Albums**: Downloaded albums with multiple discs now display tracks grouped by disc
- Visual disc separator header showing "Disc 1", "Disc 2", etc.
- Tracks sorted by disc number first, then by track number
- Single-disc albums display normally without separators
- Fixes confusion when albums have duplicate track numbers across discs
- **Album Grouping in Recents**: Downloads now show as albums instead of individual tracks in the Recent section
- Prevents flooding the recents list when downloading full albums
- Groups tracks by album name and artist
- Tapping navigates directly to the downloaded album screen
- Shows the most recent download time for each album
### Changed
- **FFmpeg FLAC-to-MP3 Conversion**: Improved conversion process
- MP3 files now saved in the same folder as FLAC (no separate MP3 subfolder)
- Original FLAC file automatically deleted after successful conversion
- New `embedMetadataToMp3()` method for MP3-specific tag embedding
- **Sticky Header Theme Integration**: AppBar background uses `colorScheme.surface` instead of dominant color when collapsed
- Dark theme: Black background with white text
- Light theme: White background with black text
- Matches Spotify's behavior for better readability
### Fixed
- **Empty Catch Blocks**: Fixed analyzer warnings for empty catch blocks
- `download_queue_provider.dart`: Added comments explaining why polling errors are silently ignored
- `track_provider.dart`: Added comments explaining why availability check errors are silently ignored
- `ffmpeg_service.dart`: Added proper error logging for temp file cleanup failures
- **Russian Plural Forms**: Fixed ICU syntax warnings in Russian localization
- Removed redundant `=1` clauses that were overriding `one` plural category
- Affected 10 plural strings including track counts and delete confirmations
- Plurals now correctly handle Russian grammar (1 трек, 2 трека, 5 треков)
### Dependencies
- Added `palette_generator: ^0.3.3+4` for cover art color extraction
---
## [3.1.1] - 2026-01-17
### Added
+36
View File
@@ -3252,6 +3252,36 @@ abstract class AppLocalizations {
/// **'24-bit / up to 192kHz'**
String get qualityHiResFlacMaxSubtitle;
/// Quality option - MP3 lossy format
///
/// In en, this message translates to:
/// **'MP3'**
String get qualityMp3;
/// Technical spec for MP3
///
/// In en, this message translates to:
/// **'320kbps (converted from FLAC)'**
String get qualityMp3Subtitle;
/// Setting - enable MP3 quality option
///
/// In en, this message translates to:
/// **'Enable MP3 Option'**
String get enableMp3Option;
/// Subtitle when MP3 is enabled
///
/// In en, this message translates to:
/// **'MP3 quality option is available'**
String get enableMp3OptionSubtitleOn;
/// Subtitle when MP3 is disabled
///
/// In en, this message translates to:
/// **'Downloads FLAC then converts to 320kbps MP3'**
String get enableMp3OptionSubtitleOff;
/// Note about quality availability
///
/// In en, this message translates to:
@@ -3588,6 +3618,12 @@ abstract class AppLocalizations {
/// **'Select tracks to delete'**
String get downloadedAlbumSelectToDelete;
/// Header for disc separator in multi-disc albums
///
/// In en, this message translates to:
/// **'Disc {discNumber}'**
String downloadedAlbumDiscHeader(int discNumber);
/// Extension capability - utility functions
///
/// In en, this message translates to:
+21
View File
@@ -1792,6 +1792,22 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1983,6 +1999,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+21
View File
@@ -1794,6 +1794,22 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / hingga 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (konversi dari FLAC)';
@override
String get enableMp3Option => 'Aktifkan Opsi MP3';
@override
String get enableMp3OptionSubtitleOn => 'Opsi kualitas MP3 tersedia';
@override
String get enableMp3OptionSubtitleOff =>
'Unduh FLAC lalu konversi ke MP3 320kbps';
@override
String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@@ -1986,6 +2002,11 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Pilih lagu untuk dihapus';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Fungsi Utilitas';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / 最大 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+31 -10
View File
@@ -74,9 +74,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -87,9 +87,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count альбомов',
one: '1 альбом',
many: '$count альбомов',
few: '$count альбома',
one: '$count альбом',
);
return '$_temp0';
}
@@ -489,9 +489,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -523,9 +523,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count релизов',
one: '1 релиз',
many: '$count релизов',
few: '$count релиза',
one: '$count релиз',
);
return '$_temp0';
}
@@ -901,9 +901,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0 из истории?\n\nЭто также удалит файлы из хранилища.';
}
@@ -946,9 +946,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалено $count $_temp0';
}
@@ -1095,9 +1095,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0';
}
@@ -1510,9 +1510,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: '$count треков',
one: '1 трек',
many: '$count треков',
few: '$count трека',
one: '$count трек',
);
return '$_temp0';
}
@@ -1820,6 +1820,22 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-бит / до 192кГц';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе';
@@ -1976,9 +1992,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0 из этого альбома?\n\nЭто также удалит файлы из хранилища.';
}
@@ -2008,9 +2024,9 @@ class AppLocalizationsRu extends AppLocalizations {
count,
locale: localeName,
other: 'треков',
one: 'трек',
many: 'треков',
few: 'трека',
one: 'трек',
);
return 'Удалить $count $_temp0';
}
@@ -2018,6 +2034,11 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Выберите треки для удаления';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Функции утилиты';
+21
View File
@@ -1782,6 +1782,22 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get qualityHiResFlacMaxSubtitle => '24-bit / up to 192kHz';
@override
String get qualityMp3 => 'MP3';
@override
String get qualityMp3Subtitle => '320kbps (converted from FLAC)';
@override
String get enableMp3Option => 'Enable MP3 Option';
@override
String get enableMp3OptionSubtitleOn => 'MP3 quality option is available';
@override
String get enableMp3OptionSubtitleOff =>
'Downloads FLAC then converts to 320kbps MP3';
@override
String get qualityNote =>
'Actual quality depends on track availability from the service';
@@ -1973,6 +1989,11 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadedAlbumSelectToDelete => 'Select tracks to delete';
@override
String downloadedAlbumDiscHeader(int discNumber) {
return 'Disc $discNumber';
}
@override
String get utilityFunctions => 'Utility Functions';
+17
View File
@@ -1320,6 +1320,16 @@
"@qualityHiResFlacMax": {"description": "Quality option - maximum resolution FLAC"},
"qualityHiResFlacMaxSubtitle": "24-bit / up to 192kHz",
"@qualityHiResFlacMaxSubtitle": {"description": "Technical spec for hi-res max"},
"qualityMp3": "MP3",
"@qualityMp3": {"description": "Quality option - MP3 lossy format"},
"qualityMp3Subtitle": "320kbps (converted from FLAC)",
"@qualityMp3Subtitle": {"description": "Technical spec for MP3"},
"enableMp3Option": "Enable MP3 Option",
"@enableMp3Option": {"description": "Setting - enable MP3 quality option"},
"enableMp3OptionSubtitleOn": "MP3 quality option is available",
"@enableMp3OptionSubtitleOn": {"description": "Subtitle when MP3 is enabled"},
"enableMp3OptionSubtitleOff": "Downloads FLAC then converts to 320kbps MP3",
"@enableMp3OptionSubtitleOff": {"description": "Subtitle when MP3 is disabled"},
"qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"},
@@ -1459,6 +1469,13 @@
},
"downloadedAlbumSelectToDelete": "Select tracks to delete",
"@downloadedAlbumSelectToDelete": {"description": "Placeholder when nothing selected"},
"downloadedAlbumDiscHeader": "Disc {discNumber}",
"@downloadedAlbumDiscHeader": {
"description": "Header for disc separator in multi-disc albums",
"placeholders": {
"discNumber": {"type": "int", "example": "1"}
}
},
"utilityFunctions": "Utility Functions",
"@utilityFunctions": {"description": "Extension capability - utility functions"},
+325 -2253
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -85,7 +85,7 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"historyTracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@historyTracksCount": {
"description": "Track count with plural form",
"placeholders": {
@@ -94,7 +94,7 @@
}
}
},
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} =1 {1 альбом} other {{count} альбомов}}",
"historyAlbumsCount": "{count, plural, one {{count} альбом} few {{count} альбома} many {{count} альбомов} other {{count} альбомов}}",
"@historyAlbumsCount": {
"description": "Album count with plural form",
"placeholders": {
@@ -596,7 +596,7 @@
"@albumTitle": {
"description": "Album screen title"
},
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"albumTracks": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@albumTracks": {
"description": "Album track count",
"placeholders": {
@@ -633,7 +633,7 @@
"@artistCompilations": {
"description": "Section header for compilations"
},
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} =1 {1 релиз} other {{count} релизов}}",
"artistReleases": "{count, plural, one {{count} релиз} few {{count} релиза} many {{count} релизов} other {{count} релизов}}",
"@artistReleases": {
"description": "Artist release count",
"placeholders": {
@@ -1108,7 +1108,7 @@
"@dialogDeleteSelectedTitle": {
"description": "Dialog title - delete selected items"
},
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"dialogDeleteSelectedMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из истории?\n\nЭто также удалит файлы из хранилища.",
"@dialogDeleteSelectedMessage": {
"description": "Dialog message - delete selected tracks",
"placeholders": {
@@ -1169,7 +1169,7 @@
"@snackbarCredentialsCleared": {
"description": "Snackbar - Spotify credentials removed"
},
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"snackbarDeletedTracks": "Удалено {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@snackbarDeletedTracks": {
"description": "Snackbar - tracks deleted",
"placeholders": {
@@ -1376,7 +1376,7 @@
"@selectionTapToSelect": {
"description": "Hint - how to select items"
},
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"selectionDeleteTracks": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@selectionDeleteTracks": {
"description": "Delete button with count",
"placeholders": {
@@ -1916,7 +1916,7 @@
}
}
},
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} =1 {1 трек} other {{count} треков}}",
"tracksCount": "{count, plural, one {{count} трек} few {{count} трека} many {{count} треков} other {{count} треков}}",
"@tracksCount": {
"description": "Track count display",
"placeholders": {
@@ -2520,7 +2520,7 @@
"@downloadedAlbumDeleteSelected": {
"description": "Button - delete selected tracks"
},
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"downloadedAlbumDeleteMessage": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}} из этого альбома?\n\nЭто также удалит файлы из хранилища.",
"@downloadedAlbumDeleteMessage": {
"description": "Delete confirmation with count",
"placeholders": {
@@ -2559,7 +2559,7 @@
"@downloadedAlbumTapToSelect": {
"description": "Selection hint"
},
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} =1{трек} other{треков}}",
"downloadedAlbumDeleteCount": "Удалить {count} {count, plural, one {трек} few {трека} many {треков} other{треков}}",
"@downloadedAlbumDeleteCount": {
"description": "Delete button text with count",
"placeholders": {
+4
View File
@@ -31,6 +31,7 @@ class AppSettings {
final String albumFolderStructure; // artist_album, album_only, artist_year_album, year_album
final bool showExtensionStore; // Show Extension Store tab in navigation
final String locale; // App language: 'system', 'en', 'id', etc.
final bool enableMp3Option; // Enable MP3 quality option (default off, requires FFmpeg conversion)
const AppSettings({
this.defaultService = 'tidal',
@@ -60,6 +61,7 @@ class AppSettings {
this.albumFolderStructure = 'artist_album', // Default: Albums/Artist/Album
this.showExtensionStore = true, // Default: show store
this.locale = 'system', // Default: follow system language
this.enableMp3Option = false, // Default: disabled
});
AppSettings copyWith({
@@ -91,6 +93,7 @@ class AppSettings {
String? albumFolderStructure,
bool? showExtensionStore,
String? locale,
bool? enableMp3Option,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -120,6 +123,7 @@ class AppSettings {
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore,
locale: locale ?? this.locale,
enableMp3Option: enableMp3Option ?? this.enableMp3Option,
);
}
+2
View File
@@ -36,6 +36,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
json['albumFolderStructure'] as String? ?? 'artist_album',
showExtensionStore: json['showExtensionStore'] as bool? ?? true,
locale: json['locale'] as String? ?? 'system',
enableMp3Option: json['enableMp3Option'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -67,4 +68,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore,
'locale': instance.locale,
'enableMp3Option': instance.enableMp3Option,
};
+166 -1
View File
@@ -588,6 +588,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
} catch (e) {
// Silently ignore polling errors to avoid spamming logs
// Polling is not critical and will retry on next interval
}
});
}
@@ -1126,13 +1128,139 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (await coverFile.exists()) {
await coverFile.delete();
}
} catch (_) {}
} catch (e) {
_log.w('Failed to cleanup cover file: $e');
}
}
} catch (e) {
_log.e('Failed to embed metadata: $e');
}
}
/// Embed metadata, lyrics, and cover to a MP3 file
Future<void> _embedMetadataToMp3(String mp3Path, Track track) async {
final settings = ref.read(settingsProvider);
String? coverPath;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality for MP3: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_mp3_$uniqueId.jpg';
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(coverUrl));
final response = await request.close();
if (response.statusCode == 200) {
final file = File(coverPath);
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
_log.d('Cover downloaded for MP3: $coverPath');
} else {
_log.w('Failed to download cover for MP3: HTTP ${response.statusCode}');
coverPath = null;
}
httpClient.close();
} catch (e) {
_log.e('Failed to download cover for MP3: $e');
coverPath = null;
}
}
try {
final metadata = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
'ALBUM': track.albumName,
};
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
track.artistName;
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString();
}
if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!;
metadata['YEAR'] = track.releaseDate!.split('-').first;
}
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
_log.d('MP3 Metadata map content: $metadata');
// Fetch lyrics if embedLyrics is enabled
if (settings.embedLyrics) {
try {
final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
track.name,
track.artistName,
filePath: '',
durationMs: durationMs,
);
if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
}
} catch (e) {
_log.w('Failed to fetch lyrics for MP3 embedding: $e');
}
}
_log.d('Embedding tags to MP3: $metadata');
final result = await FFmpegService.embedMetadataToMp3(
mp3Path: mp3Path,
coverPath: coverPath != null && await File(coverPath).exists()
? coverPath
: null,
metadata: metadata,
);
if (result != null) {
_log.d('Metadata, lyrics, and cover embedded to MP3 via FFmpeg');
} else {
_log.w('FFmpeg MP3 metadata/cover embed failed');
}
if (coverPath != null) {
try {
final coverFile = File(coverPath);
if (await coverFile.exists()) {
await coverFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup MP3 cover file: $e');
}
}
} catch (e) {
_log.e('Failed to embed metadata to MP3: $e');
}
}
Future<void> _processQueue() async {
if (state.isProcessing) return; // Prevent multiple concurrent processing
@@ -1677,6 +1805,43 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return;
}
// Convert FLAC to MP3 if MP3 quality was selected
if (quality == 'MP3' && filePath != null && filePath.endsWith('.flac')) {
_log.i('MP3 quality selected, converting FLAC to MP3...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.97,
);
try {
final mp3Path = await FFmpegService.convertFlacToMp3(
filePath,
bitrate: '320k',
deleteOriginal: true,
);
if (mp3Path != null) {
filePath = mp3Path;
actualQuality = 'MP3 320kbps';
_log.i('Successfully converted to MP3: $mp3Path');
// Embed metadata, lyrics, and cover to the MP3 file
_log.i('Embedding metadata to MP3...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
await _embedMetadataToMp3(mp3Path, trackToDownload);
} else {
_log.w('MP3 conversion failed, keeping FLAC file');
}
} catch (e) {
_log.e('MP3 conversion error: $e, keeping FLAC file');
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
+9
View File
@@ -223,6 +223,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
state = state.copyWith(locale: locale);
_saveSettings();
}
void setEnableMp3Option(bool enabled) {
state = state.copyWith(enableMp3Option: enabled);
// If MP3 is disabled and current quality is MP3, reset to LOSSLESS
if (!enabled && state.audioQuality == 'MP3') {
state = state.copyWith(audioQuality: 'LOSSLESS');
}
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+2
View File
@@ -477,6 +477,8 @@ class TrackNotifier extends Notifier<TrackState> {
tracks[index] = updatedTrack;
state = state.copyWith(tracks: tracks);
} catch (e) {
// Silently ignore availability check errors
// This is a background operation that shouldn't disrupt the user
}
}
+143 -60
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -60,11 +61,16 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
List<Track>? _tracks;
bool _isLoading = false;
String? _error;
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.albumId.startsWith('deezer:') ? 'deezer' : 'spotify';
ref.read(recentAccessProvider.notifier).recordAlbumAccess(
@@ -80,6 +86,42 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (_tracks == null) {
_fetchTracks();
}
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
// Show title in AppBar when scrolled past the header (320 - kToolbarHeight + info card top)
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
Future<void> _fetchTracks() async {
@@ -143,6 +185,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme),
@@ -167,74 +210,105 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
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),
),
),
),
),
),
],
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.albumName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
@@ -244,6 +318,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
final tracks = _tracks ?? [];
final artistName = tracks.isNotEmpty ? tracks.first.artistName : null;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -260,7 +336,14 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
),
const SizedBox(height: 8),
if (artistName != null && artistName.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
artistName,
style: Theme.of(context).textTheme.titleMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
const SizedBox(height: 12),
if (tracks.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
+34 -3
View File
@@ -95,11 +95,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
String? _headerImageUrl;
int? _monthlyListeners;
String? _error;
// Sticky title state
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
@override
void initState() {
super.initState();
// Setup scroll listener for sticky title
_scrollController.addListener(_onScroll);
WidgetsBinding.instance.addPostFrameCallback((_) {
final providerId = widget.extensionId ??
(widget.artistId.startsWith('deezer:') ? 'deezer' : 'spotify');
@@ -141,9 +148,24 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
} else {
_fetchDiscography();
}
}
void _onScroll() {
// Show title when scrolled past the header (280px trigger)
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
Future<void> _fetchDiscography() async {
setState(() => _isLoadingDiscography = true);
try {
@@ -256,8 +278,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final singles = albums.where((a) => a.albumType == 'single').toList();
final compilations = albums.where((a) => a.albumType == 'compilation').toList();
return Scaffold(
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildHeader(context, colorScheme),
if (_isLoadingDiscography)
@@ -307,12 +330,20 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
listenersText = context.l10n.artistMonthlyListeners(formatter.format(listeners));
}
return SliverAppBar(
return SliverAppBar(
expandedHeight: 380,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.artistName,
style: TextStyle(color: colorScheme.onSurface),
),
),
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
+231 -66
View File
@@ -3,6 +3,7 @@ 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:palette_generator/palette_generator.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
@@ -29,6 +30,49 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bool _isSelectionMode = false;
final Set<String> _selectedIds = {};
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
@@ -38,6 +82,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return itemKey == albumKey;
}).toList()
..sort((a, b) {
// Sort by disc number first, then by track number
final aDisc = a.discNumber ?? 1;
final bDisc = b.discNumber ?? 1;
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
final aNum = a.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999;
if (aNum != bNum) return aNum.compareTo(bNum);
@@ -45,6 +93,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
});
}
/// Get unique disc numbers from tracks (sorted)
List<int> _getDiscNumbers(List<DownloadHistoryItem> tracks) {
final discNumbers = tracks
.map((t) => t.discNumber ?? 1)
.toSet()
.toList()
..sort();
return discNumbers;
}
/// Check if album has multiple discs
bool _hasMultipleDiscs(List<DownloadHistoryItem> tracks) {
return _getDiscNumbers(tracks).length > 1;
}
/// Get tracks for a specific disc
List<DownloadHistoryItem> _getTracksForDisc(List<DownloadHistoryItem> tracks, int discNumber) {
return tracks.where((t) => (t.discNumber ?? 1) == discNumber).toList();
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
@@ -187,6 +255,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
body: Stack(
children: [
CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, colorScheme, tracks),
@@ -211,69 +280,97 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
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),
),
),
),
),
),
],
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.albumName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(
@@ -388,16 +485,84 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
// Check if album has multiple discs
if (!_hasMultipleDiscs(tracks)) {
// Single disc - use simple list
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
// Multiple discs - build list with separators
final discNumbers = _getDiscNumbers(tracks);
final List<Widget> children = [];
for (final discNumber in discNumbers) {
final discTracks = _getTracksForDisc(tracks, discNumber);
if (discTracks.isEmpty) continue;
// Add disc separator
children.add(_buildDiscSeparator(context, colorScheme, discNumber));
// Add tracks for this disc
for (final track in discTracks) {
children.add(
KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
);
}
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
}
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Container(
height: 1,
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
],
),
);
}
+29 -6
View File
@@ -17,6 +17,7 @@ import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
@@ -650,12 +651,25 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
Widget _buildRecentAccess(List<RecentAccessItem> items, ColorScheme colorScheme) {
final historyItems = ref.read(downloadHistoryProvider).items;
final downloadItems = historyItems.take(10).where((h) => h.spotifyId != null && h.spotifyId!.isNotEmpty).map((h) => RecentAccessItem(
id: h.spotifyId!,
name: h.trackName,
subtitle: h.artistName,
// Group download history by album to avoid flooding recents with individual tracks
final albumMap = <String, DownloadHistoryItem>{};
for (final h in historyItems) {
// Use album name + artist as unique key
final albumKey = '${h.albumName}|${h.albumArtist ?? h.artistName}';
// Keep the most recent download for each album
if (!albumMap.containsKey(albumKey) ||
h.downloadedAt.isAfter(albumMap[albumKey]!.downloadedAt)) {
albumMap[albumKey] = h;
}
}
// Convert grouped albums to RecentAccessItem with album type
final downloadItems = albumMap.values.take(10).map((h) => RecentAccessItem(
id: '${h.albumName}|${h.albumArtist ?? h.artistName}', // Use album key as ID
name: h.albumName,
subtitle: h.albumArtist ?? h.artistName,
imageUrl: h.coverUrl,
type: RecentAccessType.track,
type: RecentAccessType.album,
accessedAt: h.downloadedAt,
providerId: 'download',
)).toList();
@@ -815,7 +829,16 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
));
}
case RecentAccessType.album:
if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') {
// Handle downloaded albums - navigate to DownloadedAlbumScreen
if (item.providerId == 'download') {
Navigator.push(context, MaterialPageRoute(
builder: (context) => DownloadedAlbumScreen(
albumName: item.name,
artistName: item.subtitle ?? '',
coverUrl: item.imageUrl,
),
));
} else if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && item.providerId != 'spotify') {
Navigator.push(context, MaterialPageRoute(
builder: (context) => ExtensionAlbumScreen(
extensionId: item.providerId!,
+167 -62
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/models/download_item.dart';
@@ -10,7 +11,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart';
/// Playlist detail screen with Material Expressive 3 design
class PlaylistScreen extends ConsumerWidget {
class PlaylistScreen extends ConsumerStatefulWidget {
final String playlistName;
final String? coverUrl;
final List<Track> tracks;
@@ -23,16 +24,66 @@ class PlaylistScreen extends ConsumerWidget {
});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<PlaylistScreen> createState() => _PlaylistScreenState();
}
class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
_buildAppBar(context, colorScheme),
_buildInfoCard(context, ref, colorScheme),
_buildInfoCard(context, colorScheme),
_buildTrackListHeader(context, colorScheme),
_buildTrackList(context, ref, colorScheme),
_buildTrackList(context, colorScheme),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],
),
@@ -40,59 +91,113 @@ class PlaylistScreen extends ConsumerWidget {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final bgColor = _dominantColor ?? colorScheme.surface;
return SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
fit: StackFit.expand,
children: [
if (coverUrl != null)
CachedNetworkImage(imageUrl: 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: coverUrl != null
? CachedNetworkImage(imageUrl: coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.playlist_play, size: 48, color: colorScheme.onSurfaceVariant)),
),
),
),
),
],
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
widget.playlistName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.playlist_play, size: 64, color: colorScheme.onSurfaceVariant),
),
),
),
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
);
},
),
leading: IconButton(
icon: Container(padding: const EdgeInsets.all(8), decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle), child: Icon(Icons.arrow_back, color: colorScheme.onSurface)),
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.8),
shape: BoxShape.circle,
),
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
),
onPressed: () => Navigator.pop(context),
),
);
}
Widget _buildInfoCard(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -105,7 +210,7 @@ class PlaylistScreen extends ConsumerWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
Text(widget.playlistName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -115,15 +220,15 @@ class PlaylistScreen extends ConsumerWidget {
children: [
Icon(Icons.playlist_play, size: 14, color: colorScheme.onTertiaryContainer),
const SizedBox(width: 4),
Text(context.l10n.tracksCount(tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(context.l10n.tracksCount(widget.tracks.length), style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
],
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: () => _downloadAll(context, ref),
onPressed: () => _downloadAll(context),
icon: const Icon(Icons.download),
label: Text(context.l10n.downloadAllCount(tracks.length)),
label: Text(context.l10n.downloadAllCount(widget.tracks.length)),
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
),
],
@@ -149,25 +254,25 @@ class PlaylistScreen extends ConsumerWidget {
);
}
Widget _buildTrackList(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
final track = widget.tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _PlaylistTrackItem(
track: track,
onDownload: () => _downloadTrack(context, ref, track),
onDownload: () => _downloadTrack(context, track),
),
);
},
childCount: tracks.length,
childCount: widget.tracks.length,
),
);
}
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
@@ -186,22 +291,22 @@ class PlaylistScreen extends ConsumerWidget {
}
}
void _downloadAll(BuildContext context, WidgetRef ref) {
if (tracks.isEmpty) return;
void _downloadAll(BuildContext context) {
if (widget.tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show(
context,
trackName: '${tracks.length} tracks',
artistName: playlistName,
trackName: '${widget.tracks.length} tracks',
artistName: widget.playlistName,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
},
);
} else {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(tracks.length))));
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(widget.tracks, settings.defaultService);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedTracksToQueue(widget.tracks.length))));
}
}
}
@@ -99,6 +99,17 @@ class DownloadSettingsPage extends ConsumerWidget {
.read(settingsProvider.notifier)
.setAskQualityBeforeDownload(value),
),
SettingsSwitchItem(
icon: Icons.audiotrack,
title: context.l10n.enableMp3Option,
subtitle: settings.enableMp3Option
? context.l10n.enableMp3OptionSubtitleOn
: context.l10n.enableMp3OptionSubtitleOff,
value: settings.enableMp3Option,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setEnableMp3Option(value),
),
if (!settings.askQualityBeforeDownload && isBuiltInService) ...[
_QualityOption(
title: context.l10n.qualityFlacLossless,
@@ -123,8 +134,18 @@ class DownloadSettingsPage extends ConsumerWidget {
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('HI_RES_LOSSLESS'),
showDivider: false,
showDivider: settings.enableMp3Option,
),
if (settings.enableMp3Option)
_QualityOption(
title: context.l10n.qualityMp3,
subtitle: context.l10n.qualityMp3Subtitle,
isSelected: settings.audioQuality == 'MP3',
onTap: () => ref
.read(settingsProvider.notifier)
.setAudioQuality('MP3'),
showDivider: false,
),
],
if (!isBuiltInService) ...[
Padding(
+121 -56
View File
@@ -3,6 +3,7 @@ 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:palette_generator/palette_generator.dart';
import 'package:open_filex/open_filex.dart';
import 'package:spotiflac_android/utils/mime_utils.dart';
import 'package:url_launcher/url_launcher.dart';
@@ -28,6 +29,9 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String? _lyrics;
bool _lyricsLoading = false;
String? _lyricsError;
Color? _dominantColor;
bool _showTitleInAppBar = false;
final ScrollController _scrollController = ScrollController();
String? _normalizeOptionalString(String? value) {
if (value == null) return null;
@@ -40,7 +44,42 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_checkFile();
_extractDominantColor();
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
final shouldShow = _scrollController.offset > 280;
if (shouldShow != _showTitleInAppBar) {
setState(() => _showTitleInAppBar = shouldShow);
}
}
Future<void> _extractDominantColor() async {
if (widget.item.coverUrl == null) return;
try {
final paletteGenerator = await PaletteGenerator.fromImageProvider(
CachedNetworkImageProvider(widget.item.coverUrl!),
maximumColorCount: 16,
);
if (mounted) {
setState(() {
_dominantColor = paletteGenerator.dominantColor?.color ??
paletteGenerator.vibrantColor?.color ??
paletteGenerator.mutedColor?.color;
});
}
} catch (_) {
// Ignore palette extraction errors
}
}
Future<void> _checkFile() async {
@@ -91,21 +130,47 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final bgColor = _dominantColor ?? colorScheme.surface;
return Scaffold(
body: CustomScrollView(
controller: _scrollController,
slivers: [
SliverAppBar(
expandedHeight: 280,
expandedHeight: 320,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
flexibleSpace: FlexibleSpaceBar(
background: _buildHeaderBackground(context, colorScheme),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _showTitleInAppBar ? 1.0 : 0.0,
child: Text(
trackName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: _buildHeaderBackground(context, colorScheme, coverSize, bgColor, showContent),
stretchModes: const [
StretchMode.zoomBackground,
StretchMode.blurBackground,
],
);
},
),
leading: IconButton(
icon: Container(
@@ -167,74 +232,74 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
);
}
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme) {
Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, Color bgColor, bool showContent) {
return Stack(
fit: StackFit.expand,
children: [
if (item.coverUrl != null)
CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
color: Colors.black.withValues(alpha: 0.5),
colorBlendMode: BlendMode.darken,
),
Container(
// Background with dominant color
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
colorScheme.surface.withValues(alpha: 0.8),
bgColor,
bgColor.withValues(alpha: 0.8),
colorScheme.surface,
],
stops: const [0.0, 0.7, 1.0],
stops: const [0.0, 0.6, 1.0],
),
),
),
Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Hero(
tag: 'cover_${item.id}',
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: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
placeholder: (_, _) => Container(
// Cover image centered - fade out when collapsing
AnimatedOpacity(
duration: const Duration(milliseconds: 150),
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
child: Hero(
tag: 'cover_${item.id}',
child: Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 30,
offset: const Offset(0, 15),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
placeholder: (_, _) => Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 48,
size: 64,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
size: 48,
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
+147 -9
View File
@@ -48,18 +48,14 @@ class FFmpegService {
}
/// Convert FLAC to MP3
/// If deleteOriginal is true, deletes the FLAC file after conversion
static Future<String?> convertFlacToMp3(
String inputPath, {
String bitrate = '320k',
bool deleteOriginal = true,
}) async {
final dir = File(inputPath).parent.path;
final baseName =
inputPath.split(Platform.pathSeparator).last.replaceAll('.flac', '');
final outputDir = '$dir${Platform.pathSeparator}MP3';
await Directory(outputDir).create(recursive: true);
final outputPath = '$outputDir${Platform.pathSeparator}$baseName.mp3';
// Convert in same folder, just change extension
final outputPath = inputPath.replaceAll('.flac', '.mp3');
final command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -map_metadata 0 -id3v2_version 3 "$outputPath" -y';
@@ -67,6 +63,12 @@ class FFmpegService {
final result = await _execute(command);
if (result.success) {
// Delete original FLAC if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
} catch (_) {}
}
return outputPath;
}
@@ -201,11 +203,147 @@ class FFmpegService {
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
} catch (e) {
_log.w('Failed to cleanup temp file: $e');
}
_log.e('Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Embed metadata and cover art to MP3 file using ID3v2 tags
/// Returns the file path on success, null on failure
static Future<String?> embedMetadataToMp3({
required String mp3Path,
String? coverPath,
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
if (coverPath != null) {
cmdBuffer.write('-i "$coverPath" ');
}
cmdBuffer.write('-map 0:a ');
if (coverPath != null) {
cmdBuffer.write('-map 1:0 ');
cmdBuffer.write('-c:v:0 copy ');
cmdBuffer.write('-id3v2_version 3 ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
}
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
// Convert FLAC/Vorbis tags to ID3v2 tags for MP3
final id3Metadata = _convertToId3Tags(metadata);
id3Metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
}
cmdBuffer.write('-id3v2_version 3 "$tempOutput" -y');
final command = cmdBuffer.toString();
_log.d('Executing FFmpeg MP3 embed command: $command');
final result = await _execute(command);
if (result.success) {
try {
final tempFile = File(tempOutput);
final originalFile = File(mp3Path);
if (await tempFile.exists()) {
if (await originalFile.exists()) {
await originalFile.delete();
}
await tempFile.copy(mp3Path);
await tempFile.delete();
_log.d('MP3 metadata embedded successfully');
return mp3Path;
} else {
_log.e('Temp MP3 output file not found: $tempOutput');
return null;
}
} catch (e) {
_log.e('Failed to replace MP3 file after metadata embed: $e');
return null;
}
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup temp MP3 file: $e');
}
_log.e('MP3 Metadata/Cover embed failed: ${result.output}');
return null;
}
/// Convert FLAC/Vorbis comment tags to ID3v2 compatible tags
static Map<String, String> _convertToId3Tags(Map<String, String> vorbisMetadata) {
final id3Map = <String, String>{};
for (final entry in vorbisMetadata.entries) {
final key = entry.key.toUpperCase();
final value = entry.value;
// Map Vorbis comments to ID3v2 frame names
switch (key) {
case 'TITLE':
id3Map['title'] = value;
break;
case 'ARTIST':
id3Map['artist'] = value;
break;
case 'ALBUM':
id3Map['album'] = value;
break;
case 'ALBUMARTIST':
id3Map['album_artist'] = value;
break;
case 'TRACKNUMBER':
case 'TRACK':
id3Map['track'] = value;
break;
case 'DISCNUMBER':
case 'DISC':
id3Map['disc'] = value;
break;
case 'DATE':
case 'YEAR':
id3Map['date'] = value;
break;
case 'ISRC':
id3Map['TSRC'] = value; // ID3v2 ISRC frame
break;
case 'LYRICS':
case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value;
break;
default:
// Pass through other tags as-is
id3Map[key.toLowerCase()] = value;
}
}
return id3Map;
}
}
/// Result of FFmpeg command execution
+23 -2
View File
@@ -49,6 +49,13 @@ const _builtInServices = [
),
];
/// MP3 quality option (shown when enabled in settings)
const _mp3QualityOption = QualityOption(
id: 'MP3',
label: 'MP3',
description: '320kbps (converted from FLAC)',
);
/// A reusable widget for selecting download service (built-in + extensions)
class DownloadServicePicker extends ConsumerStatefulWidget {
final String? trackName;
@@ -105,20 +112,34 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
/// Get quality options for the selected service
List<QualityOption> _getQualityOptions() {
final settings = ref.read(settingsProvider);
final builtIn = _builtInServices.where((s) => s.id == _selectedService).firstOrNull;
if (builtIn != null) {
// Add MP3 option if enabled in settings
if (settings.enableMp3Option) {
return [...builtIn.qualityOptions, _mp3QualityOption];
}
return builtIn.qualityOptions;
}
final extensionState = ref.read(extensionProvider);
final ext = extensionState.extensions.where((e) => e.id == _selectedService).firstOrNull;
if (ext != null && ext.qualityOptions.isNotEmpty) {
// Add MP3 option for extensions too if enabled
if (settings.enableMp3Option) {
return [...ext.qualityOptions, _mp3QualityOption];
}
return ext.qualityOptions;
}
return const [
QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
// Default fallback options
final defaultOptions = [
const QualityOption(id: 'DEFAULT', label: 'Default Quality', description: 'Best available'),
];
if (settings.enableMp3Option) {
return [...defaultOptions, _mp3QualityOption];
}
return defaultOptions;
}
@override
+8
View File
@@ -653,6 +653,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
palette_generator:
dependency: "direct main"
description:
name: palette_generator
sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed"
url: "https://pub.dev"
source: hosted
version: "0.3.3+7"
path:
dependency: transitive
description:
+2 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none"
version: 3.1.1+60
version: 3.1.2+61
environment:
sdk: ^3.10.0
@@ -38,6 +38,7 @@ dependencies:
# Material Expressive 3 / Dynamic Color
dynamic_color: ^1.7.0
material_color_utilities: ^0.11.1
palette_generator: ^0.3.3+4
# Permissions
permission_handler: ^12.0.1