feat: responsive layout tuning, cache management page, and improved recent access UX

- Add responsive scaling across album, artist, playlist, downloaded album, local album, queue, setup, and tutorial screens to prevent overflow on smaller devices
- Add new Storage & Cache management page (Settings > Storage & Cache) with per-category clear and cleanup actions
- Extract normalizedHeaderTopPadding utility for consistent app bar padding
- Improve home search Recent Access behavior: show when focused with empty input, hide stale results during active recent mode
- Add excluded-downloaded-count tracking to local library scan stats
- Add recentEmpty and recentShowAllDownloads l10n keys (EN + ID)
- Add full cache management l10n keys (EN + ID)
- Fix about_page indentation and formatting consistency
- Fix appearance_settings_page formatting
- Fix downloaded_album_screen and local_album_screen formatting and responsive sizing
This commit is contained in:
zarzet 2026-02-09 12:19:20 +07:00
parent 5fdf1df5df
commit 23f5aa11b0
52 changed files with 5184 additions and 1565 deletions

View file

@ -1,5 +1,35 @@
# Changelog
## [3.5.3] - 2026-02-09
### Added
- CSV import flow now includes a new option: **Skip already downloaded songs** before enqueueing tracks
- Added regression test suite for cross-script matching behavior in Go backend (`go_backend/matching_test.go`)
### Changed
- CSV import confirmation dialog now supports filtering out tracks already present in download history (matched by Spotify ID and ISRC)
- CSV import enqueue feedback now reports added/skipped counts when duplicate downloads are skipped
- Home search now prioritizes **Recent Access** when search field is focused with empty input, even if old search results still exist in memory
- Search filter/result sections are now hidden while Recent Access mode is active to avoid stale-result overlap
- Recent Access now shows a localized empty-state message when no recent items are available
- Normalized collapsing AppBar top inset across iOS/Android so header height/animation stays visually consistent on Apple devices
- Storage & Cache UX improved: `Clear all cache` now preserves web/runtime cache by default (optional), with explicit warnings/actions for runtime cache resets
- Local library settings now include a display count for tracks excluded because they already exist in download history
- Responsive layout tuning applied across key screens to reduce hardcoded-height overflow issues on smaller devices
### Fixed
- Fixed false-positive cross-script matching in Qobuz/Tidal where unrelated titles/artists in different scripts could be incorrectly accepted
- Cross-script title/artist matching now requires transliteration-aware normalization and strict similarity checks instead of auto-accepting script differences
- Qobuz metadata fallback no longer scans all results when zero title matches are found; title verification is now required
- Qobuz metadata final validation now rejects results when title does not match expected track name
- Fixed Home search regression where Recent Access panel could disappear after previous searches
- Fixed Local Library card/layout crash caused by `Flex` usage under unbounded height constraints
- Hardened FFmpeg metadata embedding temp-file naming to prevent rare collisions during parallel downloads/fallback flows (Qobuz → Tidal) that could cause missing embedded metadata
- Fixed SAF external lyrics naming where some providers saved `.lrc` files as `.lrc.txt`; LRC export now uses neutral MIME to preserve `.lrc` extension
## [3.5.2] - 2026-02-08
### Performance

View file

@ -1,59 +0,0 @@
package gobackend
import "testing"
func TestQobuzTitlesMatchCrossScript(t *testing.T) {
t.Run("rejects unrelated cross-script titles", func(t *testing.T) {
if qobuzTitlesMatch("パンツ脱げるもん!", "Warrior of the Darkness") {
t.Fatalf("expected unrelated cross-script titles to not match")
}
})
t.Run("accepts transliterated japanese title", func(t *testing.T) {
if !qobuzTitlesMatch("パンツ脱げるもん!", "Pantsu Nugeru Mon") {
t.Fatalf("expected transliterated japanese title to match")
}
})
}
func TestQobuzArtistsMatchCrossScript(t *testing.T) {
t.Run("rejects unrelated cross-script artists", func(t *testing.T) {
if qobuzArtistsMatch("TakeponG", "陳奕迅") {
t.Fatalf("expected unrelated cross-script artists to not match")
}
})
t.Run("accepts transliterated japanese artist", func(t *testing.T) {
if !qobuzArtistsMatch("たけぽんぐ", "takepong") {
t.Fatalf("expected transliterated japanese artist to match")
}
})
}
func TestTidalTitlesMatchCrossScript(t *testing.T) {
t.Run("rejects unrelated cross-script titles", func(t *testing.T) {
if titlesMatch("パンツ脱げるもん!", "Warrior of the Darkness") {
t.Fatalf("expected unrelated cross-script titles to not match")
}
})
t.Run("accepts transliterated japanese title", func(t *testing.T) {
if !titlesMatch("パンツ脱げるもん!", "Pantsu Nugeru Mon") {
t.Fatalf("expected transliterated japanese title to match")
}
})
}
func TestTidalArtistsMatchCrossScript(t *testing.T) {
t.Run("rejects unrelated cross-script artists", func(t *testing.T) {
if artistsMatch("TakeponG", "陳奕迅") {
t.Fatalf("expected unrelated cross-script artists to not match")
}
})
t.Run("accepts transliterated japanese artist", func(t *testing.T) {
if !artistsMatch("たけぽんぐ", "takepong") {
t.Fatalf("expected transliterated japanese artist to match")
}
})
}

View file

@ -81,160 +81,13 @@ func qobuzArtistsMatch(expectedArtist, foundArtist string) bool {
expectedLatin := qobuzIsLatinScript(expectedArtist)
foundLatin := qobuzIsLatinScript(foundArtist)
if expectedLatin != foundLatin {
if qobuzCrossScriptEquivalent(expectedArtist, foundArtist) {
GoLog("[Qobuz] Artist names in different scripts but transliteration matched: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
}
return false
}
func qobuzNormalizeScriptAware(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = CleanToASCII(JapaneseToRomaji(normalized))
normalized = strings.Join(strings.Fields(normalized), " ")
return strings.TrimSpace(normalized)
}
func qobuzCrossScriptEquivalent(expected, found string) bool {
normExpected := qobuzNormalizeScriptAware(expected)
normFound := qobuzNormalizeScriptAware(found)
if normExpected == "" || normFound == "" {
return false
}
if normExpected == normFound {
GoLog("[Qobuz] Artist names in different scripts, assuming match: '%s' vs '%s'\n", expectedArtist, foundArtist)
return true
}
compactExpected := strings.ReplaceAll(normExpected, " ", "")
compactFound := strings.ReplaceAll(normFound, " ", "")
if len(compactExpected) >= 6 && len(compactFound) >= 6 {
if compactExpected == compactFound ||
strings.Contains(compactExpected, compactFound) ||
strings.Contains(compactFound, compactExpected) {
return true
}
shorterLen := len(compactExpected)
if len(compactFound) < shorterLen {
shorterLen = len(compactFound)
}
maxDistance := 1
if shorterLen >= 10 {
maxDistance = 2
}
if shorterLen >= 16 {
maxDistance = 3
}
if qobuzEditDistanceWithin(compactExpected, compactFound, maxDistance) {
if qobuzCommonPrefixLen(compactExpected, compactFound) >= 4 ||
qobuzCommonSuffixLen(compactExpected, compactFound) >= 4 {
return true
}
}
}
return false
}
func qobuzCommonPrefixLen(a, b string) int {
max := len(a)
if len(b) < max {
max = len(b)
}
count := 0
for i := 0; i < max; i++ {
if a[i] != b[i] {
break
}
count++
}
return count
}
func qobuzCommonSuffixLen(a, b string) int {
i := len(a) - 1
j := len(b) - 1
count := 0
for i >= 0 && j >= 0 {
if a[i] != b[j] {
break
}
count++
i--
j--
}
return count
}
func qobuzEditDistanceWithin(a, b string, maxDistance int) bool {
if maxDistance < 0 {
return false
}
if a == b {
return true
}
lenA := len(a)
lenB := len(b)
diff := lenA - lenB
if diff < 0 {
diff = -diff
}
if diff > maxDistance {
return false
}
prev := make([]int, lenB+1)
for j := 0; j <= lenB; j++ {
prev[j] = j
}
for i := 1; i <= lenA; i++ {
curr := make([]int, lenB+1)
curr[0] = i
minInRow := curr[0]
for j := 1; j <= lenB; j++ {
cost := 0
if a[i-1] != b[j-1] {
cost = 1
}
insertCost := curr[j-1] + 1
deleteCost := prev[j] + 1
replaceCost := prev[j-1] + cost
best := insertCost
if deleteCost < best {
best = deleteCost
}
if replaceCost < best {
best = replaceCost
}
curr[j] = best
if best < minInRow {
minInRow = best
}
}
if minInRow > maxDistance {
return false
}
prev = curr
}
return prev[lenB] <= maxDistance
}
func qobuzSplitArtists(artists string) []string {
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
@ -324,10 +177,8 @@ func qobuzTitlesMatch(expectedTitle, foundTitle string) bool {
expectedLatin := qobuzIsLatinScript(expectedTitle)
foundLatin := qobuzIsLatinScript(foundTitle)
if expectedLatin != foundLatin {
if qobuzCrossScriptEquivalent(expectedTitle, foundTitle) {
GoLog("[Qobuz] Titles in different scripts but transliteration matched: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
GoLog("[Qobuz] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
return false
@ -851,11 +702,8 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
GoLog("[Qobuz] Title matches: %d out of %d results\n", len(titleMatches), len(allTracks))
tracksToCheck := titleMatches
if strings.TrimSpace(trackName) != "" && len(titleMatches) == 0 {
return nil, fmt.Errorf("no tracks found with matching title (expected '%s')", trackName)
}
if strings.TrimSpace(trackName) == "" {
if len(titleMatches) == 0 {
GoLog("[Qobuz] WARNING: No title matches for '%s', checking all %d results\n", trackName, len(allTracks))
for i := range allTracks {
tracksToCheck = append(tracksToCheck, &allTracks[i])
}
@ -1304,16 +1152,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
if track == nil {
GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName)
track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil {
if !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[Qobuz] Title mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.TrackName, track.Title)
track = nil
} else if !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, track.Performer.Name)
track = nil
}
}

View file

@ -1196,160 +1196,13 @@ func artistsMatch(spotifyArtist, tidalArtist string) bool {
spotifyLatin := isLatinScript(spotifyArtist)
tidalLatin := isLatinScript(tidalArtist)
if spotifyLatin != tidalLatin {
if crossScriptEquivalent(spotifyArtist, tidalArtist) {
GoLog("[Tidal] Artist names in different scripts but transliteration matched: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
return true
}
}
return false
}
func normalizeScriptAware(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
normalized = CleanToASCII(JapaneseToRomaji(normalized))
normalized = strings.Join(strings.Fields(normalized), " ")
return strings.TrimSpace(normalized)
}
func crossScriptEquivalent(expected, found string) bool {
normExpected := normalizeScriptAware(expected)
normFound := normalizeScriptAware(found)
if normExpected == "" || normFound == "" {
return false
}
if normExpected == normFound {
GoLog("[Tidal] Artist names in different scripts, assuming match: '%s' vs '%s'\n", spotifyArtist, tidalArtist)
return true
}
compactExpected := strings.ReplaceAll(normExpected, " ", "")
compactFound := strings.ReplaceAll(normFound, " ", "")
if len(compactExpected) >= 6 && len(compactFound) >= 6 {
if compactExpected == compactFound ||
strings.Contains(compactExpected, compactFound) ||
strings.Contains(compactFound, compactExpected) {
return true
}
shorterLen := len(compactExpected)
if len(compactFound) < shorterLen {
shorterLen = len(compactFound)
}
maxDistance := 1
if shorterLen >= 10 {
maxDistance = 2
}
if shorterLen >= 16 {
maxDistance = 3
}
if editDistanceWithin(compactExpected, compactFound, maxDistance) {
if commonPrefixLen(compactExpected, compactFound) >= 4 ||
commonSuffixLen(compactExpected, compactFound) >= 4 {
return true
}
}
}
return false
}
func commonPrefixLen(a, b string) int {
max := len(a)
if len(b) < max {
max = len(b)
}
count := 0
for i := 0; i < max; i++ {
if a[i] != b[i] {
break
}
count++
}
return count
}
func commonSuffixLen(a, b string) int {
i := len(a) - 1
j := len(b) - 1
count := 0
for i >= 0 && j >= 0 {
if a[i] != b[j] {
break
}
count++
i--
j--
}
return count
}
func editDistanceWithin(a, b string, maxDistance int) bool {
if maxDistance < 0 {
return false
}
if a == b {
return true
}
lenA := len(a)
lenB := len(b)
diff := lenA - lenB
if diff < 0 {
diff = -diff
}
if diff > maxDistance {
return false
}
prev := make([]int, lenB+1)
for j := 0; j <= lenB; j++ {
prev[j] = j
}
for i := 1; i <= lenA; i++ {
curr := make([]int, lenB+1)
curr[0] = i
minInRow := curr[0]
for j := 1; j <= lenB; j++ {
cost := 0
if a[i-1] != b[j-1] {
cost = 1
}
insertCost := curr[j-1] + 1
deleteCost := prev[j] + 1
replaceCost := prev[j-1] + cost
best := insertCost
if deleteCost < best {
best = deleteCost
}
if replaceCost < best {
best = replaceCost
}
curr[j] = best
if best < minInRow {
minInRow = best
}
}
if minInRow > maxDistance {
return false
}
prev = curr
}
return prev[lenB] <= maxDistance
}
func splitArtists(artists string) []string {
normalized := artists
normalized = strings.ReplaceAll(normalized, " feat. ", "|")
@ -1439,10 +1292,8 @@ func titlesMatch(expectedTitle, foundTitle string) bool {
expectedLatin := isLatinScript(expectedTitle)
foundLatin := isLatinScript(foundTitle)
if expectedLatin != foundLatin {
if crossScriptEquivalent(expectedTitle, foundTitle) {
GoLog("[Tidal] Titles in different scripts but transliteration matched: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
GoLog("[Tidal] Titles in different scripts, assuming match: '%s' vs '%s'\n", expectedTitle, foundTitle)
return true
}
return false

View file

@ -712,6 +712,12 @@ abstract class AppLocalizations {
/// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'**
String get optionsSpotifyWarning;
/// Warning about Spotify API deprecation
///
/// In en, this message translates to:
/// **'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.'**
String get optionsSpotifyDeprecationWarning;
/// Extensions page title
///
/// In en, this message translates to:
@ -3520,6 +3526,24 @@ abstract class AppLocalizations {
/// **'Album Folder Structure'**
String get downloadAlbumFolderStructure;
/// Setting - choose whether artist folders use Album Artist or Track Artist
///
/// In en, this message translates to:
/// **'Use Album Artist for folders'**
String get downloadUseAlbumArtistForFolders;
/// Subtitle when Album Artist is used for folder naming
///
/// In en, this message translates to:
/// **'Artist folders use Album Artist when available'**
String get downloadUseAlbumArtistForFoldersAlbumSubtitle;
/// Subtitle when Track Artist is used for folder naming
///
/// In en, this message translates to:
/// **'Artist folders use Track Artist only'**
String get downloadUseAlbumArtistForFoldersTrackSubtitle;
/// Setting - output file format
///
/// In en, this message translates to:
@ -3934,6 +3958,18 @@ abstract class AppLocalizations {
/// **'Playlist'**
String get recentTypePlaylist;
/// Empty state text for recent access list
///
/// In en, this message translates to:
/// **'No recent items yet'**
String get recentEmpty;
/// Button label to unhide hidden downloads in recent access
///
/// In en, this message translates to:
/// **'Show All Downloads'**
String get recentShowAllDownloads;
/// Snackbar message when tapping playlist in recent access
///
/// In en, this message translates to:
@ -4102,6 +4138,18 @@ abstract class AppLocalizations {
/// **'Scan music & detect duplicates'**
String get settingsLocalLibrarySubtitle;
/// Settings menu item - cache management
///
/// In en, this message translates to:
/// **'Storage & Cache'**
String get settingsCache;
/// Subtitle for cache management menu
///
/// In en, this message translates to:
/// **'View size and clear cached data'**
String get settingsCacheSubtitle;
/// Library settings page title
///
/// In en, this message translates to:
@ -4785,6 +4833,204 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'No orphaned entries found'**
String get cleanupOrphanedDownloadsNone;
/// Cache management page title
///
/// In en, this message translates to:
/// **'Storage & Cache'**
String get cacheTitle;
/// Heading for cache summary card
///
/// In en, this message translates to:
/// **'Cache overview'**
String get cacheSummaryTitle;
/// Helper text for cache summary card
///
/// In en, this message translates to:
/// **'Clearing cache will not remove downloaded music files.'**
String get cacheSummarySubtitle;
/// Total cache size shown in summary
///
/// In en, this message translates to:
/// **'Estimated cache usage: {size}'**
String cacheEstimatedTotal(String size);
/// Section header for cache entries
///
/// In en, this message translates to:
/// **'Cached Data'**
String get cacheSectionStorage;
/// Section header for cleanup actions
///
/// In en, this message translates to:
/// **'Maintenance'**
String get cacheSectionMaintenance;
/// Cache item title for app cache directory
///
/// In en, this message translates to:
/// **'App cache directory'**
String get cacheAppDirectory;
/// Description of what app cache directory contains
///
/// In en, this message translates to:
/// **'HTTP responses, WebView data, and other temporary app data.'**
String get cacheAppDirectoryDesc;
/// Cache item title for temporary files directory
///
/// In en, this message translates to:
/// **'Temporary directory'**
String get cacheTempDirectory;
/// Description of what temporary directory contains
///
/// In en, this message translates to:
/// **'Temporary files from downloads and audio conversion.'**
String get cacheTempDirectoryDesc;
/// Cache item title for persistent cover images
///
/// In en, this message translates to:
/// **'Cover image cache'**
String get cacheCoverImage;
/// Description of what cover image cache contains
///
/// In en, this message translates to:
/// **'Downloaded album and track cover art. Will re-download when viewed.'**
String get cacheCoverImageDesc;
/// Cache item title for local library cover art images
///
/// In en, this message translates to:
/// **'Library cover cache'**
String get cacheLibraryCover;
/// Description of what library cover cache contains
///
/// In en, this message translates to:
/// **'Cover art extracted from local music files. Will re-extract on next scan.'**
String get cacheLibraryCoverDesc;
/// Cache item title for explore home feed cache
///
/// In en, this message translates to:
/// **'Explore feed cache'**
String get cacheExploreFeed;
/// Description of what explore feed cache contains
///
/// In en, this message translates to:
/// **'Explore tab content (new releases, trending). Will refresh on next visit.'**
String get cacheExploreFeedDesc;
/// Cache item title for track ID lookup cache
///
/// In en, this message translates to:
/// **'Track lookup cache'**
String get cacheTrackLookup;
/// Description of what track lookup cache contains
///
/// In en, this message translates to:
/// **'Spotify/Deezer track ID lookups. Clearing may slow next few searches.'**
String get cacheTrackLookupDesc;
/// Description of what cleanup unused data does
///
/// In en, this message translates to:
/// **'Remove orphaned download history and library entries for missing files.'**
String get cacheCleanupUnusedDesc;
/// Label when cache category has no data
///
/// In en, this message translates to:
/// **'No cached data'**
String get cacheNoData;
/// Cache size and file count
///
/// In en, this message translates to:
/// **'{size} in {count} files'**
String cacheSizeWithFiles(String size, int count);
/// Cache size only
///
/// In en, this message translates to:
/// **'{size}'**
String cacheSizeOnly(String size);
/// Track cache entry count
///
/// In en, this message translates to:
/// **'{count} entries'**
String cacheEntries(int count);
/// Snackbar after clearing selected cache
///
/// In en, this message translates to:
/// **'Cleared: {target}'**
String cacheClearSuccess(String target);
/// Dialog title before clearing one cache category
///
/// In en, this message translates to:
/// **'Clear cache?'**
String get cacheClearConfirmTitle;
/// Dialog message before clearing selected cache
///
/// In en, this message translates to:
/// **'This will clear cached data for {target}. Downloaded music files will not be deleted.'**
String cacheClearConfirmMessage(String target);
/// Dialog title before clearing all caches
///
/// In en, this message translates to:
/// **'Clear all cache?'**
String get cacheClearAllConfirmTitle;
/// Dialog message before clearing all caches
///
/// In en, this message translates to:
/// **'This will clear all cache categories on this page. Downloaded music files will not be deleted.'**
String get cacheClearAllConfirmMessage;
/// Button label to clear all caches
///
/// In en, this message translates to:
/// **'Clear all cache'**
String get cacheClearAll;
/// Action title for cleaning unused entries
///
/// In en, this message translates to:
/// **'Cleanup unused data'**
String get cacheCleanupUnused;
/// Subtitle for cleanup unused data action
///
/// In en, this message translates to:
/// **'Remove orphaned download history and missing library entries'**
String get cacheCleanupUnusedSubtitle;
/// Snackbar after unused data cleanup
///
/// In en, this message translates to:
/// **'Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries'**
String cacheCleanupResult(int downloadCount, int libraryCount);
/// Button label to refresh cache statistics
///
/// In en, this message translates to:
/// **'Refresh stats'**
String get cacheRefreshStats;
}
class _AppLocalizationsDelegate

View file

@ -352,6 +352,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Erweiterungen';
@ -1941,6 +1945,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2176,6 +2191,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2282,6 +2303,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2694,4 +2721,127 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -343,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@ -1926,6 +1930,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2161,6 +2176,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2267,6 +2288,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2679,4 +2706,127 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -343,6 +343,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@ -1926,6 +1930,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2161,6 +2176,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2267,6 +2288,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2679,6 +2706,129 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}
/// The translations for Spanish Castilian, as used in Spain (`es_ES`).

View file

@ -343,6 +343,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@ -1926,6 +1930,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2161,6 +2176,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2267,6 +2288,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2679,4 +2706,127 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -343,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@ -1926,6 +1930,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2161,6 +2176,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2267,6 +2288,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2679,4 +2706,127 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -347,6 +347,10 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.';
@override
String get extensionsTitle => 'Ekstensi';
@ -1938,6 +1942,18 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Struktur Folder Album';
@override
String get downloadUseAlbumArtistForFolders =>
'Gunakan Album Artist untuk folder';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Folder artis memakai Album Artist jika tersedia';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Folder artis hanya memakai Track Artist';
@override
String get downloadSaveFormat => 'Simpan Format';
@ -2174,6 +2190,12 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'Belum ada item terbaru';
@override
String get recentShowAllDownloads => 'Tampilkan Semua Download';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2280,6 +2302,12 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Penyimpanan & Cache';
@override
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
@override
String get libraryTitle => 'Local Library';
@ -2694,4 +2722,127 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone =>
'Tidak ada entri unduhan tidak valid';
@override
String get cacheTitle => 'Penyimpanan & Cache';
@override
String get cacheSummaryTitle => 'Ringkasan cache';
@override
String get cacheSummarySubtitle =>
'Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimasi penggunaan cache: $size';
}
@override
String get cacheSectionStorage => 'Data Cache';
@override
String get cacheSectionMaintenance => 'Perawatan';
@override
String get cacheAppDirectory => 'Direktori cache aplikasi';
@override
String get cacheAppDirectoryDesc =>
'Respons HTTP, data WebView, dan data sementara aplikasi.';
@override
String get cacheTempDirectory => 'Direktori sementara';
@override
String get cacheTempDirectoryDesc =>
'File sementara dari proses download dan konversi audio.';
@override
String get cacheCoverImage => 'Cache gambar cover';
@override
String get cacheCoverImageDesc =>
'Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.';
@override
String get cacheLibraryCover => 'Cache cover library';
@override
String get cacheLibraryCoverDesc =>
'Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.';
@override
String get cacheExploreFeed => 'Cache feed Explore';
@override
String get cacheExploreFeedDesc =>
'Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.';
@override
String get cacheTrackLookup => 'Cache pencocokan lagu';
@override
String get cacheTrackLookupDesc =>
'Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.';
@override
String get cacheCleanupUnusedDesc =>
'Hapus entri riwayat download dan library yang filenya sudah tidak ada.';
@override
String get cacheNoData => 'Tidak ada data cache';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size dalam $count file';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entri';
}
@override
String cacheClearSuccess(String target) {
return 'Berhasil dibersihkan: $target';
}
@override
String get cacheClearConfirmTitle => 'Bersihkan cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'Ini akan membersihkan data cache untuk $target. File musik yang sudah diunduh tidak akan dihapus.';
}
@override
String get cacheClearAllConfirmTitle => 'Bersihkan semua cache?';
@override
String get cacheClearAllConfirmMessage =>
'Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.';
@override
String get cacheClearAll => 'Bersihkan semua cache';
@override
String get cacheCleanupUnused => 'Bersihkan data tidak terpakai';
@override
String get cacheCleanupUnusedSubtitle =>
'Hapus riwayat unduhan yatim dan entri library yang file-nya hilang';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Pembersihan selesai: $downloadCount unduhan yatim, $libraryCount entri library hilang';
}
@override
String get cacheRefreshStats => 'Segarkan statistik';
}

View file

@ -340,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => '拡張';
@ -1914,6 +1918,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => '形式を保存';
@ -2147,6 +2162,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get recentTypePlaylist => 'プレイリスト';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'プレイリスト: $name';
@ -2253,6 +2274,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2665,4 +2692,127 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -343,6 +343,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@ -1926,6 +1930,17 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2161,6 +2176,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2267,6 +2288,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2679,4 +2706,127 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -343,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@ -1926,6 +1930,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2161,6 +2176,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2267,6 +2288,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2679,4 +2706,127 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -343,6 +343,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@ -1926,6 +1930,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2161,6 +2176,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2267,6 +2288,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2679,6 +2706,129 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}
/// The translations for Portuguese, as used in Portugal (`pt_PT`).

View file

@ -354,6 +354,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Расширения';
@ -1964,6 +1968,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Структура папок альбома';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Формат сохранения';
@ -2206,6 +2221,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get recentTypePlaylist => 'Плейлист';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Плейлист: $name';
@ -2313,6 +2334,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2725,4 +2752,127 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -348,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Eklentiler';
@ -1941,6 +1945,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2176,6 +2191,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2282,6 +2303,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2694,4 +2721,127 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}

View file

@ -343,6 +343,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com';
@override
String get optionsSpotifyDeprecationWarning =>
'Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.';
@override
String get extensionsTitle => 'Extensions';
@ -1926,6 +1930,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get downloadAlbumFolderStructure => 'Album Folder Structure';
@override
String get downloadUseAlbumArtistForFolders => 'Use Album Artist for folders';
@override
String get downloadUseAlbumArtistForFoldersAlbumSubtitle =>
'Artist folders use Album Artist when available';
@override
String get downloadUseAlbumArtistForFoldersTrackSubtitle =>
'Artist folders use Track Artist only';
@override
String get downloadSaveFormat => 'Save Format';
@ -2161,6 +2176,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override
String recentPlaylistInfo(String name) {
return 'Playlist: $name';
@ -2267,6 +2288,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override
String get libraryTitle => 'Local Library';
@ -2679,6 +2706,129 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found';
@override
String get cacheTitle => 'Storage & Cache';
@override
String get cacheSummaryTitle => 'Cache overview';
@override
String get cacheSummarySubtitle =>
'Clearing cache will not remove downloaded music files.';
@override
String cacheEstimatedTotal(String size) {
return 'Estimated cache usage: $size';
}
@override
String get cacheSectionStorage => 'Cached Data';
@override
String get cacheSectionMaintenance => 'Maintenance';
@override
String get cacheAppDirectory => 'App cache directory';
@override
String get cacheAppDirectoryDesc =>
'HTTP responses, WebView data, and other temporary app data.';
@override
String get cacheTempDirectory => 'Temporary directory';
@override
String get cacheTempDirectoryDesc =>
'Temporary files from downloads and audio conversion.';
@override
String get cacheCoverImage => 'Cover image cache';
@override
String get cacheCoverImageDesc =>
'Downloaded album and track cover art. Will re-download when viewed.';
@override
String get cacheLibraryCover => 'Library cover cache';
@override
String get cacheLibraryCoverDesc =>
'Cover art extracted from local music files. Will re-extract on next scan.';
@override
String get cacheExploreFeed => 'Explore feed cache';
@override
String get cacheExploreFeedDesc =>
'Explore tab content (new releases, trending). Will refresh on next visit.';
@override
String get cacheTrackLookup => 'Track lookup cache';
@override
String get cacheTrackLookupDesc =>
'Spotify/Deezer track ID lookups. Clearing may slow next few searches.';
@override
String get cacheCleanupUnusedDesc =>
'Remove orphaned download history and library entries for missing files.';
@override
String get cacheNoData => 'No cached data';
@override
String cacheSizeWithFiles(String size, int count) {
return '$size in $count files';
}
@override
String cacheSizeOnly(String size) {
return '$size';
}
@override
String cacheEntries(int count) {
return '$count entries';
}
@override
String cacheClearSuccess(String target) {
return 'Cleared: $target';
}
@override
String get cacheClearConfirmTitle => 'Clear cache?';
@override
String cacheClearConfirmMessage(String target) {
return 'This will clear cached data for $target. Downloaded music files will not be deleted.';
}
@override
String get cacheClearAllConfirmTitle => 'Clear all cache?';
@override
String get cacheClearAllConfirmMessage =>
'This will clear all cache categories on this page. Downloaded music files will not be deleted.';
@override
String get cacheClearAll => 'Clear all cache';
@override
String get cacheCleanupUnused => 'Cleanup unused data';
@override
String get cacheCleanupUnusedSubtitle =>
'Remove orphaned download history and missing library entries';
@override
String cacheCleanupResult(int downloadCount, int libraryCount) {
return 'Cleanup completed: $downloadCount orphaned downloads, $libraryCount missing library entries';
}
@override
String get cacheRefreshStats => 'Refresh stats';
}
/// The translations for Chinese, as used in China (`zh_CN`).

View file

@ -241,6 +241,8 @@
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"},
"optionsSpotifyDeprecationWarning": "Spotify search will be deprecated on March 3, 2026 due to Spotify API changes. Please switch to Deezer.",
"@optionsSpotifyDeprecationWarning": {"description": "Warning about Spotify API deprecation"},
"extensionsTitle": "Extensions",
"@extensionsTitle": {"description": "Extensions page title"},
@ -1421,6 +1423,12 @@
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
"downloadAlbumFolderStructure": "Album Folder Structure",
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"},
"downloadUseAlbumArtistForFolders": "Use Album Artist for folders",
"@downloadUseAlbumArtistForFolders": {"description": "Setting - choose whether artist folders use Album Artist or Track Artist"},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Artist folders use Album Artist when available",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {"description": "Subtitle when Album Artist is used for folder naming"},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Artist folders use Track Artist only",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {"description": "Subtitle when Track Artist is used for folder naming"},
"downloadSaveFormat": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service",
@ -1594,6 +1602,12 @@
"@recentTypeSong": {"description": "Recent access item type - song/track"},
"recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {"description": "Recent access item type - playlist"},
"recentEmpty": "No recent items yet",
"@recentEmpty": {"description": "Empty state text for recent access list"},
"recentShowAllDownloads": "Show All Downloads",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
@ -1704,6 +1718,10 @@
"@settingsLocalLibrary": {"description": "Settings menu item - local library"},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"},
"settingsCache": "Storage & Cache",
"@settingsCache": {"description": "Settings menu item - cache management"},
"settingsCacheSubtitle": "View size and clear cached data",
"@settingsCacheSubtitle": {"description": "Subtitle for cache management menu"},
"libraryTitle": "Local Library",
"@libraryTitle": {"description": "Library settings page title"},
"libraryStatus": "Library Status",
@ -2010,5 +2028,109 @@
}
},
"cleanupOrphanedDownloadsNone": "No orphaned entries found",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"}
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
"cacheTitle": "Storage & Cache",
"@cacheTitle": {"description": "Cache management page title"},
"cacheSummaryTitle": "Cache overview",
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
"cacheSummarySubtitle": "Clearing cache will not remove downloaded music files.",
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
"cacheEstimatedTotal": "Estimated cache usage: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheSectionStorage": "Cached Data",
"@cacheSectionStorage": {"description": "Section header for cache entries"},
"cacheSectionMaintenance": "Maintenance",
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
"cacheAppDirectory": "App cache directory",
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
"cacheAppDirectoryDesc": "HTTP responses, WebView data, and other temporary app data.",
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
"cacheTempDirectory": "Temporary directory",
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
"cacheTempDirectoryDesc": "Temporary files from downloads and audio conversion.",
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
"cacheCoverImage": "Cover image cache",
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
"cacheCoverImageDesc": "Downloaded album and track cover art. Will re-download when viewed.",
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
"cacheLibraryCover": "Library cover cache",
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
"cacheLibraryCoverDesc": "Cover art extracted from local music files. Will re-extract on next scan.",
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
"cacheExploreFeed": "Explore feed cache",
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
"cacheExploreFeedDesc": "Explore tab content (new releases, trending). Will refresh on next visit.",
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
"cacheTrackLookup": "Track lookup cache",
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
"cacheTrackLookupDesc": "Spotify/Deezer track ID lookups. Clearing may slow next few searches.",
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
"cacheCleanupUnusedDesc": "Remove orphaned download history and library entries for missing files.",
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
"cacheNoData": "No cached data",
"@cacheNoData": {"description": "Label when cache category has no data"},
"cacheSizeWithFiles": "{size} in {count} files",
"@cacheSizeWithFiles": {
"description": "Cache size and file count",
"placeholders": {
"size": {"type": "String"},
"count": {"type": "int"}
}
},
"cacheSizeOnly": "{size}",
"@cacheSizeOnly": {
"description": "Cache size only",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheEntries": "{count} entries",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
"count": {"type": "int"}
}
},
"cacheClearSuccess": "Cleared: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearConfirmTitle": "Clear cache?",
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
"cacheClearConfirmMessage": "This will clear cached data for {target}. Downloaded music files will not be deleted.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearAllConfirmTitle": "Clear all cache?",
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
"cacheClearAllConfirmMessage": "This will clear all cache categories on this page. Downloaded music files will not be deleted.",
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
"cacheClearAll": "Clear all cache",
"@cacheClearAll": {"description": "Button label to clear all caches"},
"cacheCleanupUnused": "Cleanup unused data",
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
"cacheCleanupUnusedSubtitle": "Remove orphaned download history and missing library entries",
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
"cacheCleanupResult": "Cleanup completed: {downloadCount} orphaned downloads, {libraryCount} missing library entries",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
"downloadCount": {"type": "int"},
"libraryCount": {"type": "int"}
}
},
"cacheRefreshStats": "Refresh stats",
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"}
}

View file

@ -151,6 +151,14 @@
"@settingsExtensions": {
"description": "Settings section - extension management"
},
"settingsCache": "Penyimpanan & Cache",
"@settingsCache": {
"description": "Settings menu item - cache management"
},
"settingsCacheSubtitle": "Lihat ukuran dan bersihkan data cache",
"@settingsCacheSubtitle": {
"description": "Subtitle for cache management menu"
},
"settingsAbout": "Tentang",
"@settingsAbout": {
"description": "Settings section - app info"
@ -426,6 +434,10 @@
"@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement"
},
"optionsSpotifyDeprecationWarning": "Pencarian Spotify akan dihentikan pada 3 Maret 2026 karena perubahan API Spotify. Silakan beralih ke Deezer.",
"@optionsSpotifyDeprecationWarning": {
"description": "Warning about Spotify API deprecation"
},
"extensionsTitle": "Ekstensi",
"@extensionsTitle": {
"description": "Extensions page title"
@ -2465,6 +2477,18 @@
"@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization"
},
"downloadUseAlbumArtistForFolders": "Gunakan Album Artist untuk folder",
"@downloadUseAlbumArtistForFolders": {
"description": "Setting - choose whether artist folders use Album Artist or Track Artist"
},
"downloadUseAlbumArtistForFoldersAlbumSubtitle": "Folder artis memakai Album Artist jika tersedia",
"@downloadUseAlbumArtistForFoldersAlbumSubtitle": {
"description": "Subtitle when Album Artist is used for folder naming"
},
"downloadUseAlbumArtistForFoldersTrackSubtitle": "Folder artis hanya memakai Track Artist",
"@downloadUseAlbumArtistForFoldersTrackSubtitle": {
"description": "Subtitle when Track Artist is used for folder naming"
},
"downloadSaveFormat": "Simpan Format",
"@downloadSaveFormat": {
"description": "Setting - output file format"
@ -2727,6 +2751,14 @@
"@recentTypePlaylist": {
"description": "Recent access item type - playlist"
},
"recentEmpty": "Belum ada item terbaru",
"@recentEmpty": {
"description": "Empty state text for recent access list"
},
"recentShowAllDownloads": "Tampilkan Semua Download",
"@recentShowAllDownloads": {
"description": "Button label to unhide hidden downloads in recent access"
},
"recentPlaylistInfo": "Playlist: {name}",
"@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access",
@ -2857,166 +2889,270 @@
}
}
},
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
},
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
"@tutorialWelcomeTitle": {
"description": "Tutorial welcome page title"
},
"tutorialWelcomeDesc": "Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
"@tutorialWelcomeDesc": {
"description": "Tutorial welcome page description"
},
"tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
"tutorialWelcomeTip3": "Metadata, cover art, dan lirik otomatis tertanam",
"@tutorialWelcomeTip3": {
"description": "Tutorial welcome tip 3"
},
"tutorialSearchTitle": "Mencari Musik",
"@tutorialSearchTitle": {
"description": "Tutorial search page title"
},
"tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
"@tutorialSearchDesc": {
"description": "Tutorial search page description"
},
"tutorialSearchTip1": "Tempel URL Spotify atau Deezer langsung di kotak pencarian",
"@tutorialSearchTip1": {
"description": "Tutorial search tip 1"
},
"tutorialSearchTip2": "Atau ketik nama lagu, artis, atau album untuk mencari",
"@tutorialSearchTip2": {
"description": "Tutorial search tip 2"
},
"tutorialSearchTip3": "Mendukung lagu, album, playlist, dan halaman artis",
"@tutorialSearchTip3": {
"description": "Tutorial search tip 3"
},
"tutorialDownloadTitle": "Mengunduh Musik",
"@tutorialDownloadTitle": {
"description": "Tutorial download page title"
},
"tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini caranya.",
"@tutorialDownloadDesc": {
"description": "Tutorial download page description"
},
"tutorialDownloadTip1": "Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh",
"@tutorialDownloadTip1": {
"description": "Tutorial download tip 1"
},
"tutorialDownloadTip2": "Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)",
"@tutorialDownloadTip2": {
"description": "Tutorial download tip 2"
},
"tutorialDownloadTip3": "Unduh seluruh album atau playlist dengan satu ketukan",
"@tutorialDownloadTip3": {
"description": "Tutorial download tip 3"
},
"tutorialLibraryTitle": "Perpustakaan Anda",
"@tutorialLibraryTitle": {
"description": "Tutorial library page title"
},
"tutorialLibraryDesc": "Semua musik yang Anda unduh terorganisir di tab Perpustakaan.",
"@tutorialLibraryDesc": {
"description": "Tutorial library page description"
},
"tutorialLibraryTip1": "Lihat progres unduhan dan antrian di tab Perpustakaan",
"@tutorialLibraryTip1": {
"description": "Tutorial library tip 1"
},
"tutorialLibraryTip2": "Ketuk lagu mana pun untuk memutarnya dengan pemutar musik",
"@tutorialLibraryTip2": {
"description": "Tutorial library tip 2"
},
"tutorialLibraryTip3": "Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik",
"@tutorialLibraryTip3": {
"description": "Tutorial library tip 3"
},
"tutorialExtensionsTitle": "Ekstensi",
"@tutorialExtensionsTitle": {
"description": "Tutorial extensions page title"
},
"tutorialExtensionsDesc": "Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.",
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Jelajahi tab Toko untuk menemukan ekstensi berguna",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
"tutorialExtensionsTip2": "Tambahkan provider unduhan atau sumber pencarian baru",
"@tutorialExtensionsTip2": {
"description": "Tutorial extensions tip 2"
},
"tutorialExtensionsTip3": "Dapatkan lirik, metadata lebih baik, dan fitur lainnya",
"@tutorialExtensionsTip3": {
"description": "Tutorial extensions tip 3"
},
"tutorialSettingsTitle": "Sesuaikan Pengalaman Anda",
"@tutorialSettingsTitle": {
"description": "Tutorial settings page title"
},
"tutorialSettingsDesc": "Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.",
"@tutorialSettingsDesc": {
"description": "Tutorial settings page description"
},
"tutorialSettingsTip1": "Ubah lokasi unduhan dan organisasi folder",
"@tutorialSettingsTip1": {
"description": "Tutorial settings tip 1"
},
"tutorialSettingsTip2": "Atur kualitas audio dan preferensi format default",
"@tutorialSettingsTip2": {
"description": "Tutorial settings tip 2"
},
"tutorialSettingsTip3": "Sesuaikan tema dan tampilan aplikasi",
"@tutorialSettingsTip3": {
"description": "Tutorial settings tip 3"
},
"tutorialReadyMessage": "Anda siap! Mulai unduh musik favorit Anda sekarang.",
"@tutorialReadyMessage": {
"description": "Tutorial completion message"
},
"tutorialExample": "CONTOH",
"@tutorialExample": {
"description": "Example label in tutorial"
},
"libraryForceFullScan": "Pindai Ulang Penuh",
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
"libraryForceFullScanSubtitle": "Pindai ulang semua file, abaikan cache",
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
"cleanupOrphanedDownloads": "Bersihkan Entri Unduhan Tidak Valid",
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
"cleanupOrphanedDownloadsSubtitle": "Hapus entri riwayat untuk file yang tidak ada lagi",
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
"cleanupOrphanedDownloadsResult": "Menghapus {count} entri unduhan tidak valid dari riwayat",
"@cleanupOrphanedDownloadsResult": {
"description": "Snackbar message after orphan cleanup",
"placeholders": {
"count": {"type": "int"}
}
},
"cleanupOrphanedDownloadsNone": "Tidak ada entri unduhan tidak valid",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"}
}
"discographyNoAlbums": "No albums available",
"@discographyNoAlbums": {
"description": "Error - no albums found for artist"
},
"discographyFailedToFetch": "Failed to fetch some albums",
"@discographyFailedToFetch": {
"description": "Error - some albums failed to load"
},
"tutorialWelcomeTitle": "Selamat Datang di SpotiFLAC!",
"@tutorialWelcomeTitle": {
"description": "Tutorial welcome page title"
},
"tutorialWelcomeDesc": "Mari pelajari cara mengunduh musik favorit Anda dalam kualitas lossless. Tutorial singkat ini akan menunjukkan dasar-dasarnya.",
"@tutorialWelcomeDesc": {
"description": "Tutorial welcome page description"
},
"tutorialWelcomeTip1": "Unduh musik dari Spotify, Deezer, atau tempel URL yang didukung",
"@tutorialWelcomeTip1": {
"description": "Tutorial welcome tip 1"
},
"tutorialWelcomeTip2": "Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Amazon Music",
"@tutorialWelcomeTip2": {
"description": "Tutorial welcome tip 2"
},
"tutorialWelcomeTip3": "Metadata, cover art, dan lirik otomatis tertanam",
"@tutorialWelcomeTip3": {
"description": "Tutorial welcome tip 3"
},
"tutorialSearchTitle": "Mencari Musik",
"@tutorialSearchTitle": {
"description": "Tutorial search page title"
},
"tutorialSearchDesc": "Ada dua cara mudah untuk menemukan musik yang ingin Anda unduh.",
"@tutorialSearchDesc": {
"description": "Tutorial search page description"
},
"tutorialSearchTip1": "Tempel URL Spotify atau Deezer langsung di kotak pencarian",
"@tutorialSearchTip1": {
"description": "Tutorial search tip 1"
},
"tutorialSearchTip2": "Atau ketik nama lagu, artis, atau album untuk mencari",
"@tutorialSearchTip2": {
"description": "Tutorial search tip 2"
},
"tutorialSearchTip3": "Mendukung lagu, album, playlist, dan halaman artis",
"@tutorialSearchTip3": {
"description": "Tutorial search tip 3"
},
"tutorialDownloadTitle": "Mengunduh Musik",
"@tutorialDownloadTitle": {
"description": "Tutorial download page title"
},
"tutorialDownloadDesc": "Mengunduh musik itu mudah dan cepat. Begini caranya.",
"@tutorialDownloadDesc": {
"description": "Tutorial download page description"
},
"tutorialDownloadTip1": "Ketuk tombol unduh di samping lagu mana pun untuk mulai mengunduh",
"@tutorialDownloadTip1": {
"description": "Tutorial download tip 1"
},
"tutorialDownloadTip2": "Pilih kualitas yang Anda inginkan (FLAC, Hi-Res, atau MP3)",
"@tutorialDownloadTip2": {
"description": "Tutorial download tip 2"
},
"tutorialDownloadTip3": "Unduh seluruh album atau playlist dengan satu ketukan",
"@tutorialDownloadTip3": {
"description": "Tutorial download tip 3"
},
"tutorialLibraryTitle": "Perpustakaan Anda",
"@tutorialLibraryTitle": {
"description": "Tutorial library page title"
},
"tutorialLibraryDesc": "Semua musik yang Anda unduh terorganisir di tab Perpustakaan.",
"@tutorialLibraryDesc": {
"description": "Tutorial library page description"
},
"tutorialLibraryTip1": "Lihat progres unduhan dan antrian di tab Perpustakaan",
"@tutorialLibraryTip1": {
"description": "Tutorial library tip 1"
},
"tutorialLibraryTip2": "Ketuk lagu mana pun untuk memutarnya dengan pemutar musik",
"@tutorialLibraryTip2": {
"description": "Tutorial library tip 2"
},
"tutorialLibraryTip3": "Beralih antara tampilan daftar dan grid untuk penjelajahan lebih baik",
"@tutorialLibraryTip3": {
"description": "Tutorial library tip 3"
},
"tutorialExtensionsTitle": "Ekstensi",
"@tutorialExtensionsTitle": {
"description": "Tutorial extensions page title"
},
"tutorialExtensionsDesc": "Tingkatkan kemampuan aplikasi dengan ekstensi komunitas.",
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Jelajahi tab Toko untuk menemukan ekstensi berguna",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
"tutorialExtensionsTip2": "Tambahkan provider unduhan atau sumber pencarian baru",
"@tutorialExtensionsTip2": {
"description": "Tutorial extensions tip 2"
},
"tutorialExtensionsTip3": "Dapatkan lirik, metadata lebih baik, dan fitur lainnya",
"@tutorialExtensionsTip3": {
"description": "Tutorial extensions tip 3"
},
"tutorialSettingsTitle": "Sesuaikan Pengalaman Anda",
"@tutorialSettingsTitle": {
"description": "Tutorial settings page title"
},
"tutorialSettingsDesc": "Personalisasi aplikasi di Pengaturan sesuai preferensi Anda.",
"@tutorialSettingsDesc": {
"description": "Tutorial settings page description"
},
"tutorialSettingsTip1": "Ubah lokasi unduhan dan organisasi folder",
"@tutorialSettingsTip1": {
"description": "Tutorial settings tip 1"
},
"tutorialSettingsTip2": "Atur kualitas audio dan preferensi format default",
"@tutorialSettingsTip2": {
"description": "Tutorial settings tip 2"
},
"tutorialSettingsTip3": "Sesuaikan tema dan tampilan aplikasi",
"@tutorialSettingsTip3": {
"description": "Tutorial settings tip 3"
},
"tutorialReadyMessage": "Anda siap! Mulai unduh musik favorit Anda sekarang.",
"@tutorialReadyMessage": {
"description": "Tutorial completion message"
},
"tutorialExample": "CONTOH",
"@tutorialExample": {
"description": "Example label in tutorial"
},
"libraryForceFullScan": "Pindai Ulang Penuh",
"@libraryForceFullScan": {"description": "Button to force a complete rescan of library"},
"libraryForceFullScanSubtitle": "Pindai ulang semua file, abaikan cache",
"@libraryForceFullScanSubtitle": {"description": "Subtitle for force full scan button"},
"cleanupOrphanedDownloads": "Bersihkan Entri Unduhan Tidak Valid",
"@cleanupOrphanedDownloads": {"description": "Button to remove history entries for deleted files"},
"cleanupOrphanedDownloadsSubtitle": "Hapus entri riwayat untuk file yang tidak ada lagi",
"@cleanupOrphanedDownloadsSubtitle": {"description": "Subtitle for orphaned cleanup button"},
"cleanupOrphanedDownloadsResult": "Menghapus {count} entri unduhan tidak valid dari riwayat",
"@cleanupOrphanedDownloadsResult": {
"description": "Snackbar message after orphan cleanup",
"placeholders": {
"count": {"type": "int"}
}
},
"cleanupOrphanedDownloadsNone": "Tidak ada entri unduhan tidak valid",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"},
"cacheTitle": "Penyimpanan & Cache",
"@cacheTitle": {"description": "Cache management page title"},
"cacheSummaryTitle": "Ringkasan cache",
"@cacheSummaryTitle": {"description": "Heading for cache summary card"},
"cacheSummarySubtitle": "Membersihkan cache tidak akan menghapus file musik yang sudah diunduh.",
"@cacheSummarySubtitle": {"description": "Helper text for cache summary card"},
"cacheEstimatedTotal": "Estimasi penggunaan cache: {size}",
"@cacheEstimatedTotal": {
"description": "Total cache size shown in summary",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheSectionStorage": "Data Cache",
"@cacheSectionStorage": {"description": "Section header for cache entries"},
"cacheSectionMaintenance": "Perawatan",
"@cacheSectionMaintenance": {"description": "Section header for cleanup actions"},
"cacheAppDirectory": "Direktori cache aplikasi",
"@cacheAppDirectory": {"description": "Cache item title for app cache directory"},
"cacheAppDirectoryDesc": "Respons HTTP, data WebView, dan data sementara aplikasi.",
"@cacheAppDirectoryDesc": {"description": "Description of what app cache directory contains"},
"cacheTempDirectory": "Direktori sementara",
"@cacheTempDirectory": {"description": "Cache item title for temporary files directory"},
"cacheTempDirectoryDesc": "File sementara dari proses download dan konversi audio.",
"@cacheTempDirectoryDesc": {"description": "Description of what temporary directory contains"},
"cacheCoverImage": "Cache gambar cover",
"@cacheCoverImage": {"description": "Cache item title for persistent cover images"},
"cacheCoverImageDesc": "Gambar cover album dan lagu yang diunduh. Akan diunduh ulang saat dilihat.",
"@cacheCoverImageDesc": {"description": "Description of what cover image cache contains"},
"cacheLibraryCover": "Cache cover library",
"@cacheLibraryCover": {"description": "Cache item title for local library cover art images"},
"cacheLibraryCoverDesc": "Cover dari file musik lokal. Akan diekstrak ulang saat scan berikutnya.",
"@cacheLibraryCoverDesc": {"description": "Description of what library cover cache contains"},
"cacheExploreFeed": "Cache feed Explore",
"@cacheExploreFeed": {"description": "Cache item title for explore home feed cache"},
"cacheExploreFeedDesc": "Konten tab Explore (rilis baru, trending). Akan dimuat ulang saat dikunjungi.",
"@cacheExploreFeedDesc": {"description": "Description of what explore feed cache contains"},
"cacheTrackLookup": "Cache pencocokan lagu",
"@cacheTrackLookup": {"description": "Cache item title for track ID lookup cache"},
"cacheTrackLookupDesc": "Cache pencarian ID lagu Spotify/Deezer. Menghapus mungkin memperlambat beberapa pencarian.",
"@cacheTrackLookupDesc": {"description": "Description of what track lookup cache contains"},
"cacheCleanupUnusedDesc": "Hapus entri riwayat download dan library yang filenya sudah tidak ada.",
"@cacheCleanupUnusedDesc": {"description": "Description of what cleanup unused data does"},
"cacheNoData": "Tidak ada data cache",
"@cacheNoData": {"description": "Label when cache category has no data"},
"cacheSizeWithFiles": "{size} dalam {count} file",
"@cacheSizeWithFiles": {
"description": "Cache size and file count",
"placeholders": {
"size": {"type": "String"},
"count": {"type": "int"}
}
},
"cacheSizeOnly": "{size}",
"@cacheSizeOnly": {
"description": "Cache size only",
"placeholders": {
"size": {"type": "String"}
}
},
"cacheEntries": "{count} entri",
"@cacheEntries": {
"description": "Track cache entry count",
"placeholders": {
"count": {"type": "int"}
}
},
"cacheClearSuccess": "Berhasil dibersihkan: {target}",
"@cacheClearSuccess": {
"description": "Snackbar after clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearConfirmTitle": "Bersihkan cache?",
"@cacheClearConfirmTitle": {"description": "Dialog title before clearing one cache category"},
"cacheClearConfirmMessage": "Ini akan membersihkan data cache untuk {target}. File musik yang sudah diunduh tidak akan dihapus.",
"@cacheClearConfirmMessage": {
"description": "Dialog message before clearing selected cache",
"placeholders": {
"target": {"type": "String"}
}
},
"cacheClearAllConfirmTitle": "Bersihkan semua cache?",
"@cacheClearAllConfirmTitle": {"description": "Dialog title before clearing all caches"},
"cacheClearAllConfirmMessage": "Ini akan membersihkan semua kategori cache di halaman ini. File musik yang sudah diunduh tidak akan dihapus.",
"@cacheClearAllConfirmMessage": {"description": "Dialog message before clearing all caches"},
"cacheClearAll": "Bersihkan semua cache",
"@cacheClearAll": {"description": "Button label to clear all caches"},
"cacheCleanupUnused": "Bersihkan data tidak terpakai",
"@cacheCleanupUnused": {"description": "Action title for cleaning unused entries"},
"cacheCleanupUnusedSubtitle": "Hapus riwayat unduhan yatim dan entri library yang file-nya hilang",
"@cacheCleanupUnusedSubtitle": {"description": "Subtitle for cleanup unused data action"},
"cacheCleanupResult": "Pembersihan selesai: {downloadCount} unduhan yatim, {libraryCount} entri library hilang",
"@cacheCleanupResult": {
"description": "Snackbar after unused data cleanup",
"placeholders": {
"downloadCount": {"type": "int"},
"libraryCount": {"type": "int"}
}
},
"cacheRefreshStats": "Segarkan statistik",
"@cacheRefreshStats": {"description": "Button label to refresh cache statistics"}
}

View file

@ -19,6 +19,7 @@ class AppSettings {
final String updateChannel;
final bool hasSearchedBefore;
final String folderOrganization;
final bool useAlbumArtistForFolders;
final String historyViewMode;
final String historyFilterMode;
final bool askQualityBeforeDownload;
@ -63,6 +64,7 @@ class AppSettings {
this.updateChannel = 'stable',
this.hasSearchedBefore = false,
this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.historyViewMode = 'grid',
this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true,
@ -106,6 +108,7 @@ class AppSettings {
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
bool? useAlbumArtistForFolders,
String? historyViewMode,
String? historyFilterMode,
bool? askQualityBeforeDownload,
@ -149,6 +152,8 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,

View file

@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@ -68,6 +69,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload,

View file

@ -1027,14 +1027,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, {
bool separateSingles = false,
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
}) async {
String baseDir = state.outputDir;
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (separateSingles) {
final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist);
final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) {
@ -1092,7 +1094,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String subPath = '';
switch (folderOrganization) {
case 'artist':
final artistName = _sanitizeFolderName(albumArtist);
final artistName = _sanitizeFolderName(folderArtist);
subPath = artistName;
break;
case 'album':
@ -1100,7 +1102,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
subPath = albumName;
break;
case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist);
final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName);
subPath = '$artistName${Platform.pathSeparator}$albumName';
break;
@ -1144,13 +1146,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, {
bool separateSingles = false,
String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
}) async {
final albumArtist =
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
final folderArtist = useAlbumArtistForFolders
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (separateSingles) {
final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist);
final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) {
@ -1186,11 +1190,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
switch (folderOrganization) {
case 'artist':
return _sanitizeFolderName(albumArtist);
return _sanitizeFolderName(folderArtist);
case 'album':
return _sanitizeFolderName(track.albumName);
case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist);
final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName);
return '$artistName/$albumName';
default:
@ -1214,8 +1218,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case '.opus':
return 'audio/ogg';
case '.flac':
default:
return 'audio/flac';
case '.lrc':
return 'application/octet-stream';
default:
return 'application/octet-stream';
}
}
@ -2163,7 +2170,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
treeUri: treeUri,
relativeDir: relativeDir,
fileName: lrcName,
mimeType: 'text/plain',
mimeType: _mimeTypeForExt('.lrc'),
srcPath: tempPath,
);
if (uri != null) {
@ -2530,6 +2537,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
)
: '';
String? appOutputDir;
@ -2540,6 +2548,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
);
var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode;
@ -2736,6 +2745,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
);
final fallbackResult = await runDownload(
useSaf: false,

View file

@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at';
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
class LocalLibraryState {
final List<LocalLibraryItem> items;
@ -22,6 +23,7 @@ class LocalLibraryState {
final int scanErrorCount;
final bool scanWasCancelled;
final DateTime? lastScannedAt;
final int excludedDownloadedCount;
final Set<String> _isrcSet;
final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc;
@ -36,6 +38,7 @@ class LocalLibraryState {
this.scanErrorCount = 0,
this.scanWasCancelled = false,
this.lastScannedAt,
this.excludedDownloadedCount = 0,
}) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!)
@ -81,6 +84,7 @@ class LocalLibraryState {
int? scanErrorCount,
bool? scanWasCancelled,
DateTime? lastScannedAt,
int? excludedDownloadedCount,
}) {
return LocalLibraryState(
items: items ?? this.items,
@ -92,6 +96,8 @@ class LocalLibraryState {
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
excludedDownloadedCount:
excludedDownloadedCount ?? this.excludedDownloadedCount,
);
}
}
@ -126,19 +132,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
DateTime? lastScannedAt;
var excludedDownloadedCount = 0;
try {
final prefs = await SharedPreferences.getInstance();
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
}
excludedDownloadedCount =
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
} catch (e) {
_log.w('Failed to load lastScannedAt: $e');
}
state = state.copyWith(items: items, lastScannedAt: lastScannedAt);
state = state.copyWith(
items: items,
lastScannedAt: lastScannedAt,
excludedDownloadedCount: excludedDownloadedCount,
);
_log.i(
'Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt',
'Loaded ${items.length} items from library database, lastScannedAt: '
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
);
} catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack);
@ -174,8 +188,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
try {
final cacheDir = await getApplicationCacheDirectory();
final coverCacheDir = '${cacheDir.path}/library_covers';
final appSupportDir = await getApplicationSupportDirectory();
final coverCacheDir = '${appSupportDir.path}/library_covers';
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
_log.i('Cover cache directory set to: $coverCacheDir');
} catch (e) {
@ -226,6 +240,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
@ -237,9 +252,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
);
_log.i('Full scan complete: ${items.length} tracks found');
_log.i(
'Full scan complete: ${items.length} tracks found, '
'$skippedDownloads already in downloads',
);
} else {
// Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes();
@ -344,6 +363,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now');
} catch (e) {
_log.w('Failed to save lastScannedAt: $e');
@ -355,11 +375,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
);
_log.i(
'Incremental scan complete: ${items.length} total tracks '
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)',
'(${scannedList.length} new/updated, $skippedCount unchanged, '
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
);
}
} catch (e, stack) {
@ -427,6 +449,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey);
await prefs.remove(_excludedDownloadedCountKey);
} catch (e) {
_log.w('Failed to clear lastScannedAt: $e');
}

View file

@ -226,6 +226,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode);
_saveSettings();

View file

@ -233,11 +233,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@ -259,7 +265,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
@ -292,7 +299,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
left: 0,
right: 0,
bottom: 0,
height: 80,
height: bottomGradientHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@ -311,7 +318,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@ -338,7 +345,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: 64,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),

View file

@ -113,6 +113,37 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> _singlesBucket = const [];
List<ArtistAlbum> _compilationsBucket = const [];
double _responsiveScale({
double min = 0.82,
double max = 1.08,
double baseShortestSide = 390,
}) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = shortestSide / baseShortestSide;
if (scale < min) return min;
if (scale > max) return max;
return scale;
}
double _effectiveTextScale() {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
double _artistAlbumTileSize() {
final scale = _responsiveScale(min: 0.82, max: 1.05);
final textScale = _effectiveTextScale();
return 140 * scale * (1 + (textScale - 1) * 0.12);
}
double _artistAlbumSectionHeight() {
final tileSize = _artistAlbumTileSize();
final textScale = _effectiveTextScale();
return tileSize + 64 + ((textScale - 1) * 14);
}
@override
void initState() {
super.initState();
@ -1412,6 +1443,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> albums,
ColorScheme colorScheme,
) {
final sectionHeight = _artistAlbumSectionHeight();
final tileSize = _artistAlbumTileSize();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -1425,7 +1459,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
),
SizedBox(
height: 220,
height: sectionHeight,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
@ -1434,7 +1468,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final album = albums[index];
return KeyedSubtree(
key: ValueKey(album.id),
child: _buildAlbumCard(album, colorScheme),
child: _buildAlbumCard(album, colorScheme, tileSize: tileSize, sectionHeight: sectionHeight),
);
},
),
@ -1443,7 +1477,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
);
}
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
Widget _buildAlbumCard(
ArtistAlbum album,
ColorScheme colorScheme, {
required double tileSize,
required double sectionHeight,
}) {
final isSelected = _selectedAlbumIds.contains(album.id);
return GestureDetector(
@ -1460,7 +1499,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
},
child: Container(
width: 140,
width: tileSize,
height: sectionHeight,
margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -1472,19 +1512,19 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
child: album.coverUrl != null
? CachedNetworkImage(
imageUrl: album.coverUrl!,
width: 140,
height: 140,
width: tileSize,
height: tileSize,
fit: BoxFit.cover,
memCacheWidth: 280,
memCacheWidth: (tileSize * 2).round(),
cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container(
width: 140,
height: 140,
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
),
errorWidget: (context, url, error) => Container(
width: 140,
height: 140,
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
@ -1494,8 +1534,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
),
)
: Container(
width: 140,
height: 140,
width: tileSize,
height: tileSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
@ -1553,26 +1593,36 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
const SizedBox(height: 8),
Text(
album.name,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4
? album.releaseDate.substring(0, 4)
: album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
album.name,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 2),
Text(
album.totalTracks > 0
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
: album.releaseDate.length >= 4
? album.releaseDate.substring(0, 4)
: album.releaseDate,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),

View file

@ -23,7 +23,8 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
});
@override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
ConsumerState<DownloadedAlbumScreen> createState() =>
_DownloadedAlbumScreenState();
}
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
@ -53,27 +54,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
/// Get tracks for this album from history provider (reactive)
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
List<DownloadHistoryItem> _getAlbumTracks(
List<DownloadHistoryItem> allItems,
) {
return allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
// Use albumArtist if available and not empty, otherwise artistName
final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist!
: item.artistName;
// Use lowercase for case-insensitive matching
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
final itemKey =
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey =
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
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);
return a.trackName.compareTo(b.trackName);
});
}).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);
return a.trackName.compareTo(b.trackName);
});
}
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
@ -147,7 +152,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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;
@ -159,12 +164,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
}
}
@ -176,7 +183,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
@ -184,12 +193,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void _navigateToMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl);
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),
));
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),
),
);
}
void _precacheCover(String? url) {
@ -207,22 +221,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
final allHistoryItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: Center(
child: Text('No tracks found for this album'),
),
appBar: AppBar(title: Text(widget.albumName)),
body: Center(child: Text('No tracks found for this album')),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@ -248,17 +260,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
@ -267,14 +286,21 @@ 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 mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
@ -292,9 +318,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
@ -306,25 +334,35 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface),
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
placeholder: (_, _) =>
Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
),
),
),
@ -335,7 +373,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@ -352,15 +390,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
? CachedNetworkImage(
imageUrl: widget.coverUrl!,
fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(),
cacheManager: CoverCacheManager.instance,
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@ -369,14 +411,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
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),
@ -384,14 +432,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
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)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
@ -399,43 +453,70 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
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),
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)),
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),
Icon(
Icons.download_done,
size: 14,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
context.l10n.downloadedAlbumDownloadedCount(
tracks.length,
),
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),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer
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
color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
@ -462,7 +543,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@ -470,14 +555,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(
context.l10n.downloadedAlbumTracksHeader,
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,
onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
@ -485,21 +580,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final discMap = _groupTracksByDisc(tracks);
if (discMap.length <= 1) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
},
childCount: tracks.length,
),
delegate: SliverChildBuilderDelegate((context, index) {
final track = tracks[index];
return KeyedSubtree(
key: ValueKey(track.id),
child: _buildTrackItem(context, colorScheme, track),
);
}, childCount: tracks.length),
);
}
@ -524,12 +620,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
return SliverList(delegate: SliverChildListDelegate(children));
}
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
Widget _buildDiscSeparator(
BuildContext context,
ColorScheme colorScheme,
int discNumber,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
@ -543,7 +641,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6),
Text(
context.l10n.downloadedAlbumDiscHeader(discNumber),
@ -567,21 +669,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
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,
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
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -590,12 +702,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
@ -617,7 +740,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Text(
track.artistName,
@ -625,22 +750,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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),
),
),
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) {
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,
@ -684,12 +818,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
context.l10n.downloadedAlbumSelectedCount(
selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
allSelected
? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
@ -702,9 +842,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
),
],
),
@ -712,18 +861,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
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)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),

View file

@ -19,6 +19,7 @@ import 'package:spotiflac_android/screens/album_screen.dart';
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/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
@ -74,6 +75,50 @@ class _HomeTabState extends ConsumerState<HomeTab>
Set<String>? _recentAccessHiddenIdsCache;
_RecentAccessView? _recentAccessViewCache;
double _responsiveScale({
required BuildContext context,
double min = 0.82,
double max = 1.08,
double baseShortestSide = 390,
}) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = shortestSide / baseShortestSide;
if (scale < min) return min;
if (scale > max) return max;
return scale;
}
double _effectiveTextScale(BuildContext context) {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
double _recentDownloadCoverSize(BuildContext context) {
final scale = _responsiveScale(context: context, min: 0.82, max: 1.05);
final textScale = _effectiveTextScale(context);
return 100 * scale * (1 + (textScale - 1) * 0.15);
}
double _recentDownloadsRowHeight(BuildContext context) {
final coverSize = _recentDownloadCoverSize(context);
final textScale = _effectiveTextScale(context);
return coverSize + 28 + ((textScale - 1) * 8);
}
double _exploreCardSize(BuildContext context) {
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
final textScale = _effectiveTextScale(context);
return 120 * scale * (1 + (textScale - 1) * 0.12);
}
double _exploreSectionHeight(BuildContext context) {
final cardSize = _exploreCardSize(context);
final textScale = _effectiveTextScale(context);
return cardSize + 55 + ((textScale - 1) * 12);
}
@override
bool get wantKeepAlive => true;
@ -153,6 +198,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
void _onSearchFocusChanged() {
if (mounted) {
setState(() {});
}
if (_searchFocusNode.hasFocus) {
ref.read(trackProvider.notifier).setShowingRecentAccess(true);
}
@ -324,6 +372,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (trackState.albumId != null &&
trackState.albumName != null &&
trackState.tracks.isNotEmpty) {
final extensionId = trackState.searchExtensionId;
Navigator.push(
context,
MaterialPageRoute(
@ -332,6 +381,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumName: trackState.albumName!,
coverUrl: trackState.coverUrl,
tracks: trackState.tracks,
extensionId: extensionId,
),
),
);
@ -348,7 +398,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
id: trackState.playlistName!,
name: trackState.playlistName!,
imageUrl: trackState.coverUrl,
providerId: 'spotify',
providerId: trackState.searchExtensionId ?? 'spotify',
);
Navigator.push(
@ -370,6 +420,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (trackState.artistId != null &&
trackState.artistName != null &&
trackState.artistAlbums != null) {
final extensionId = trackState.searchExtensionId;
Navigator.push(
context,
MaterialPageRoute(
@ -378,6 +429,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
artistName: trackState.artistName!,
coverUrl: trackState.coverUrl,
albums: trackState.artistAlbums!,
extensionId: extensionId,
),
),
);
@ -586,7 +638,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
trackName: l10n.csvImportTracks(tracksToQueue.length),
artistName: l10n.dialogImportPlaylistTitle,
onSelect: (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
@ -662,13 +716,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
(searchArtists != null && searchArtists.isNotEmpty) ||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.isNotEmpty);
final searchText = _urlController.text.trim();
final hasSearchInput = searchText.isNotEmpty;
final isSearchFocused = _searchFocusNode.hasFocus;
final hasShortSearchInput =
hasSearchInput && searchText.length < _minLiveSearchChars;
final isShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess),
);
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
final mediaQuery = MediaQuery.of(context);
final screenHeight = mediaQuery.size.height;
final topPadding = mediaQuery.padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final historyItems = ref.watch(
downloadHistoryProvider.select((s) => s.items),
);
@ -679,13 +737,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
recentAccessProvider.select((s) => s.hiddenDownloadIds),
);
final hasRecentItems =
recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
final showRecentAccess =
isShowingRecentAccess &&
hasRecentItems &&
!hasActualResults &&
recentModeRequested &&
(!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
!isLoading;
final hasResults =
hasSearchInput || hasActualResults || isLoading || showRecentAccess;
final recentAccessView = showRecentAccess
? _getRecentAccessView(
recentAccessItems,
@ -752,7 +810,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
];
}
if (hasActualResults && isShowingRecentAccess) {
if (hasActualResults &&
isShowingRecentAccess &&
hasSearchInput &&
!isSearchFocused) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
@ -871,7 +932,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
// Search filter bar (only shown when has search results)
if (searchFilters.isNotEmpty && hasActualResults)
if (searchFilters.isNotEmpty && hasActualResults && !showRecentAccess)
SliverToBoxAdapter(
child: _buildSearchFilterBar(
searchFilters,
@ -949,7 +1010,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
isLoading: isLoading,
error: error,
colorScheme: colorScheme,
hasResults: hasResults,
hasResults: hasActualResults || isLoading,
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@ -966,6 +1027,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
ColorScheme colorScheme,
) {
final itemCount = items.length < 10 ? items.length : 10;
final coverSize = _recentDownloadCoverSize(context);
final rowHeight = _recentDownloadsRowHeight(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -980,7 +1043,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
SizedBox(
height: 130,
height: rowHeight,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: itemCount,
@ -991,7 +1054,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item),
child: Container(
width: 100,
width: coverSize,
margin: const EdgeInsets.only(right: 12),
child: Column(
children: [
@ -1000,16 +1063,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
child: item.coverUrl != null
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 100,
height: 100,
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
memCacheWidth: 200,
memCacheHeight: 200,
memCacheWidth: (coverSize * 2).round(),
memCacheHeight: (coverSize * 2).round(),
cacheManager: CoverCacheManager.instance,
)
: Container(
width: 100,
height: 100,
width: coverSize,
height: coverSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.music_note,
@ -1177,6 +1240,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) {
final sectionHeight = _exploreSectionHeight(context);
if (section.isYTMusicQuickPicks) {
return _buildYTMusicQuickPicksSection(section, colorScheme);
}
@ -1194,7 +1258,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
SizedBox(
height: 175,
height: sectionHeight,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
@ -1229,11 +1293,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) {
final isArtist = item.type == 'artist';
final cardSize = _exploreCardSize(context);
final iconSize = cardSize * 0.3;
return GestureDetector(
onTap: () => _navigateToExploreItem(item),
child: SizedBox(
width: 120,
width: cardSize,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
@ -1242,35 +1308,37 @@ class _HomeTabState extends ConsumerState<HomeTab>
: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(isArtist ? 60 : 8),
borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage(
imageUrl: item.coverUrl!,
width: 120,
height: 120,
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
memCacheWidth: 240,
memCacheHeight: 240,
memCacheWidth: (cardSize * 2).round(),
memCacheHeight: (cardSize * 2).round(),
cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container(
width: 120,
height: 120,
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getIconForType(item.type),
color: colorScheme.onSurfaceVariant,
size: 36,
size: iconSize,
),
),
)
: Container(
width: 120,
height: 120,
width: cardSize,
height: cardSize,
color: colorScheme.surfaceContainerHighest,
child: Icon(
_getIconForType(item.type),
color: colorScheme.onSurfaceVariant,
size: 36,
size: iconSize,
),
),
),
@ -1571,14 +1639,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
],
),
const SizedBox(height: 8),
if (uniqueItems.isEmpty && hasHiddenDownloads)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
if (uniqueItems.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.visibility_off,
hasHiddenDownloads ? Icons.visibility_off : Icons.history,
size: 48,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.5,
@ -1586,21 +1656,24 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
const SizedBox(height: 12),
Text(
'No recent items',
context.l10n.recentEmpty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () {
ref
.read(recentAccessProvider.notifier)
.clearHiddenDownloads();
},
icon: const Icon(Icons.visibility, size: 18),
label: const Text('Show All Downloads'),
),
if (hasHiddenDownloads) ...[
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: () {
ref
.read(recentAccessProvider.notifier)
.clearHiddenDownloads();
},
icon: const Icon(Icons.visibility, size: 18),
label: Text(context.l10n.recentShowAllDownloads),
),
],
],
),
),

View file

@ -89,7 +89,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
}
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) {
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
List<LocalLibraryItem> tracks,
) {
final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
@ -158,7 +160,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList();
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
@ -170,14 +172,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
deletedCount++;
}
}
_exitSelectionMode();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
SnackBar(
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
),
);
// Go back if all tracks were deleted
if (deletedCount == currentTracks.length) {
Navigator.pop(context);
@ -192,7 +196,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
);
}
}
@ -203,19 +209,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom;
final tracks = _sortedTracksCache;
// Show empty state if no tracks found
if (tracks.isEmpty) {
return Scaffold(
appBar: AppBar(
title: Text(widget.albumName),
),
body: const Center(
child: Text('No tracks found for this album'),
),
appBar: AppBar(title: Text(widget.albumName)),
body: const Center(child: Text('No tracks found for this album')),
);
}
final validIds = tracks.map((t) => t.id).toSet();
_selectedIds.removeWhere((id) => !validIds.contains(id));
if (_selectedIds.isEmpty && _isSelectionMode) {
@ -241,17 +243,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
],
),
AnimatedPositioned(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
left: 0,
right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
child: _buildSelectionBottomBar(
context,
colorScheme,
tracks,
bottomPadding,
),
),
],
),
@ -260,11 +269,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5;
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: colorScheme.surface,
@ -285,9 +300,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.none,
background: Stack(
@ -298,24 +315,33 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Image.file(
File(widget.coverPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
)
else
Container(color: colorScheme.surface),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
child: Container(
color: colorScheme.surface.withValues(alpha: 0.4),
),
),
),
Positioned(
left: 0, right: 0, bottom: 0, height: 80,
left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
colors: [
colorScheme.surface.withValues(alpha: 0.0),
colorScheme.surface,
],
),
),
),
@ -326,7 +352,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@ -349,13 +375,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) =>
Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
color:
colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
)
: Container(
color: colorScheme.surfaceContainerHighest,
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),
),
),
@ -364,14 +399,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
],
),
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
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),
@ -379,14 +420,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
}
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
Widget _buildInfoCard(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
@ -394,40 +441,79 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
Text(
widget.albumName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
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),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
Row(
children: [
// "Local" badge
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer),
Icon(
Icons.folder,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
'Local',
style: TextStyle(
color: colorScheme.onTertiaryContainer,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
const SizedBox(width: 8),
// Track count
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant),
Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)),
Text(
'${tracks.length} tracks',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
],
),
),
@ -435,18 +521,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
// Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
style: TextStyle(
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.onPrimaryContainer
color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
fontSize: 12,
@ -467,17 +556,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
final firstQuality =
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
for (final track in tracks) {
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) {
if (track.bitDepth != first.bitDepth ||
track.sampleRate != first.sampleRate) {
return null;
}
}
return firstQuality;
}
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@ -485,14 +580,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
Text(
context.l10n.downloadedAlbumTracksHeader,
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,
onPressed: tracks.isNotEmpty
? () => _enterSelectionMode(tracks.first.id)
: null,
icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
@ -500,15 +605,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
}
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
Widget _buildTrackList(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
final discGroups = _discGroupsCache;
final hasMultipleDiscs = _hasMultipleDiscsCache;
final slivers = <Widget>[];
for (final discNumber in _sortedDiscNumbersCache) {
final discTracks = discGroups[discNumber]!;
if (hasMultipleDiscs) {
slivers.add(
SliverToBoxAdapter(
@ -517,7 +626,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16),
@ -525,14 +637,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
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,
),
style: Theme.of(context).textTheme.labelLarge
?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
],
),
@ -550,35 +667,46 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
);
}
slivers.add(
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]),
(context, index) =>
_buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length,
),
),
);
}
return SliverMainAxisGroup(slivers: slivers);
}
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) {
Widget _buildTrackItem(
BuildContext context,
ColorScheme colorScheme,
LocalLibraryItem 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,
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
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode
? () => _toggleSelection(track.id)
: () => _openFile(track.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -587,12 +715,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
width: 24,
height: 24,
decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent,
color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle,
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
border: Border.all(
color: isSelected
? colorScheme.primary
: colorScheme.outline,
width: 2,
),
),
child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
child: isSelected
? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null,
),
const SizedBox(width: 12),
@ -614,7 +753,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
track.trackName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
),
subtitle: Row(
children: [
@ -627,27 +768,45 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
),
if (track.format != null) ...[
Text('', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)),
Text(
'',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text(
track.format!.toUpperCase(),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
],
],
),
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),
),
),
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<LocalLibraryItem> tracks, double bottomPadding) {
Widget _buildSelectionBottomBar(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
double bottomPadding,
) {
final selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
@ -694,12 +853,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount),
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
context.l10n.downloadedAlbumSelectedCount(
selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
allSelected
? context.l10n.downloadedAlbumAllSelected
: context.l10n.downloadedAlbumTapToSelect,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
@ -712,9 +877,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_selectAll(tracks);
}
},
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
icon: Icon(
allSelected ? Icons.deselect : Icons.select_all,
size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
),
],
),
@ -722,18 +896,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline),
label: Text(
selectedCount > 0
selectedCount > 0
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
: context.l10n.downloadedAlbumSelectToDelete,
),
style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
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)),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
),
),

View file

@ -390,7 +390,9 @@ class _MainShellState extends ConsumerState<MainShell> {
body: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
physics: const ClampingScrollPhysics(),
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
? const _NoSwipeRightPhysics()
: const ClampingScrollPhysics(),
children: tabs,
),
bottomNavigationBar: NavigationBar(
@ -413,6 +415,26 @@ class _MainShellState extends ConsumerState<MainShell> {
}
}
/// Custom physics that blocks swiping to the right (next page) while
/// still allowing vertical scrolling inside the page content.
class _NoSwipeRightPhysics extends ScrollPhysics {
const _NoSwipeRightPhysics({super.parent});
@override
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
// In a horizontal PageView, a negative offset means the user is
// dragging left (i.e. trying to go to the next page / right).
// Block that direction only.
if (offset < 0) return 0.0;
return super.applyPhysicsToUserOffset(position, offset);
}
}
class BouncingIcon extends StatefulWidget {
final Widget child;
const BouncingIcon({super.key, required this.child});

View file

@ -145,11 +145,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
}
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width;
final coverSize = screenWidth * 0.5; // 50% of screen width
final mediaSize = MediaQuery.of(context).size;
final screenWidth = mediaSize.width;
final shortestSide = mediaSize.shortestSide;
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
return SliverAppBar(
expandedHeight: 320,
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor:
@ -172,7 +178,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
@ -205,7 +212,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
left: 0,
right: 0,
bottom: 0,
height: 80,
height: bottomGradientHeight,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
@ -225,7 +232,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
opacity: showContent ? 1.0 : 0.0,
child: Center(
child: Padding(
padding: const EdgeInsets.only(top: 60),
padding: EdgeInsets.only(top: coverTopPadding),
child: Container(
width: coverSize,
height: coverSize,
@ -252,7 +259,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
color: colorScheme.surfaceContainerHighest,
child: Icon(
Icons.playlist_play,
size: 64,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
),

View file

@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@ -308,6 +309,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg'
String _sortMode = 'latest'; // 'latest', 'oldest', 'a-z', 'z-a'
double _effectiveTextScale() {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
double _queueCoverSize() {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = (shortestSide / 390).clamp(0.82, 1.0);
final textScale = _effectiveTextScale();
return (56 * scale * (1 + ((textScale - 1) * 0.12))).clamp(46.0, 56.0);
}
@override
void initState() {
super.initState();
@ -1550,7 +1565,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
settingsProvider.select((s) => s.historyFilterMode),
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final historyStats =
_historyStatsCache ??
@ -1632,7 +1647,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
// Search bar - always at top
if (allHistoryItems.isNotEmpty || hasQueueItems)
if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@ -2963,22 +2978,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
final coverSize = _queueCoverSize();
final memCacheSize = (coverSize * 2).round();
return item.track.coverUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: item.track.coverUrl!,
width: 56,
height: 56,
width: coverSize,
height: coverSize,
fit: BoxFit.cover,
memCacheWidth: 112,
memCacheHeight: 112,
memCacheWidth: memCacheSize,
memCacheHeight: memCacheSize,
cacheManager: CoverCacheManager.instance,
),
)
: Container(
width: 56,
height: 56,
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),

View file

@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget {
@ -12,7 +13,7 @@ class AboutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
@ -20,218 +21,229 @@ class AboutPage extends StatelessWidget {
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(
left: leftPadding,
bottom: 16,
),
title: Text(
context.l10n.aboutTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio), // 20 -> 28
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: _AppHeaderCard(),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutContributors,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
);
},
_ContributorItem(
name: AppInfo.originalAuthor,
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: _AppHeaderCard(),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutTranslators,
),
),
),
const SliverToBoxAdapter(child: _TranslatorsSection()),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: AppInfo.mobileAuthor,
description: context.l10n.aboutMobileDeveloper,
githubUsername: AppInfo.mobileAuthor,
showDivider: true,
),
_ContributorItem(
name: AppInfo.originalAuthor,
description: context.l10n.aboutOriginalCreator,
githubUsername: AppInfo.originalAuthor,
showDivider: true,
),
_ContributorItem(
name: 'Amonoman',
description: context.l10n.aboutLogoArtist,
githubUsername: 'Amonoman',
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.aboutSpecialThanks,
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutTranslators),
),
const SliverToBoxAdapter(
child: _TranslatorsSection(),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'binimum',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_ContributorItem(
name: 'sjdonado',
description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'sjdonado',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutSpotiSaver,
subtitle: context.l10n.aboutSpotiSaverDesc,
onTap: () => _launchUrl('https://spotisaver.net'),
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_ContributorItem(
name: 'binimum',
description: context.l10n.aboutBinimumDesc,
githubUsername: 'binimum',
showDivider: true,
),
_ContributorItem(
name: 'sachinsenal0x64',
description: context.l10n.aboutSachinsenalDesc,
githubUsername: 'sachinsenal0x64',
showDivider: true,
),
_ContributorItem(
name: 'sjdonado',
description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'sjdonado',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutDabMusic,
subtitle: context.l10n.aboutDabMusicDesc,
onTap: () => _launchUrl('https://dabmusic.xyz'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.music_note_outlined,
title: context.l10n.aboutSpotiSaver,
subtitle: context.l10n.aboutSpotiSaverDesc,
onTap: () => _launchUrl('https://spotisaver.net'),
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.phone_android,
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.computer,
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.telegram,
title: context.l10n.aboutTelegramChannel,
subtitle: context.l10n.aboutTelegramChannelSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'),
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.phone_android,
title: context.l10n.aboutMobileSource,
subtitle: 'github.com/${AppInfo.githubRepo}',
onTap: () => _launchUrl(AppInfo.githubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.computer,
title: context.l10n.aboutPCSource,
subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
onTap: () => _launchUrl(AppInfo.originalGithubUrl),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.bug_report_outlined,
title: context.l10n.aboutReportIssue,
subtitle: context.l10n.aboutReportIssueSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lightbulb_outline,
title: context.l10n.aboutFeatureRequest,
subtitle: context.l10n.aboutFeatureRequestSubtitle,
onTap: () => _launchUrl('${AppInfo.githubUrl}/issues/new'),
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutSocial),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.telegram,
title: context.l10n.aboutTelegramChannel,
subtitle: context.l10n.aboutTelegramChannelSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.forum_outlined,
title: context.l10n.aboutTelegramChat,
subtitle: context.l10n.aboutTelegramChatSubtitle,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'),
showDivider: false,
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
_AboutSettingsItem(
icon: Icons.info_outline,
title: context.l10n.aboutVersion,
subtitle:
'v${AppInfo.version} (build ${AppInfo.buildNumber})',
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text(
AppInfo.copyright,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
),
),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
),
),
),
);
}
@ -246,73 +258,93 @@ class _AppHeaderCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest;
return Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final logoSize = (shortestSide * 0.22).clamp(72.0, 88.0);
final contentPadding = (cardWidth * 0.06).clamp(16.0, 24.0);
final titleGap = (16 * (1 + ((textScale - 1) * 0.2))).clamp(12.0, 20.0);
return Container(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
),
padding: EdgeInsets.all(contentPadding),
child: Column(
children: [
Container(
width: logoSize,
height: logoSize,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset(
'assets/images/logo.png',
width: 88,
height: 88,
fit: BoxFit.cover,
'assets/images/logo-transparant.png',
color: colorScheme.onPrimary,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: logoSize,
height: logoSize,
fit: BoxFit.cover,
),
),
),
),
),
),
const SizedBox(height: 16),
Text(
AppInfo.appName,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
SizedBox(height: titleGap),
Text(
AppInfo.appName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'v${AppInfo.version}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(height: titleGap),
Text(
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 16),
Text(
context.l10n.aboutAppDescription,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
);
},
);
}
}
@ -333,7 +365,7 @@ class _ContributorItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -347,7 +379,7 @@ class _ContributorItem extends StatelessWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage(
child: CachedNetworkImage(
imageUrl: 'https://github.com/$githubUsername.png',
width: 40,
height: 40,
@ -380,10 +412,7 @@ child: CachedNetworkImage(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: Theme.of(context).textTheme.bodyLarge,
),
Text(name, style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(
description,
@ -485,9 +514,12 @@ class _TranslatorsSection extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface)
final cardColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest;
return Padding(
@ -501,9 +533,9 @@ class _TranslatorsSection extends StatelessWidget {
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _translators.map((translator) => _TranslatorChip(
translator: translator,
)).toList(),
children: _translators
.map((translator) => _TranslatorChip(translator: translator))
.toList(),
),
),
);
@ -535,7 +567,9 @@ class _TranslatorChip extends StatelessWidget {
radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text(
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?',
translator.name.isNotEmpty
? translator.name[0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
@ -552,10 +586,7 @@ class _TranslatorChip extends StatelessWidget {
),
),
const SizedBox(width: 6),
Text(
translator.flag,
style: const TextStyle(fontSize: 14),
),
Text(translator.flag, style: const TextStyle(fontSize: 14)),
],
),
),
@ -587,7 +618,7 @@ class _AboutSettingsItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
@ -602,31 +633,34 @@ class _AboutSettingsItem extends StatelessWidget {
SizedBox(
width: 40,
height: 40,
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24),
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,
),
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,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
],
),
),
if (onTap != null)
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
],
),
),

View file

@ -4,6 +4,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class AppearanceSettingsPage extends ConsumerWidget {
@ -14,7 +15,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
final themeSettings = ref.watch(themeProvider);
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true,
@ -22,21 +23,21 @@ class AppearanceSettingsPage extends ConsumerWidget {
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
SliverToBoxAdapter(
child: Padding(
@ -77,8 +78,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color),
),
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme),
@ -113,9 +114,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [
_LanguageSelector(
currentLocale: settings.locale,
onChanged: (locale) => ref
.read(settingsProvider.notifier)
.setLocale(locale),
onChanged: (locale) =>
ref.read(settingsProvider.notifier).setLocale(locale),
),
],
),
@ -156,151 +156,167 @@ class _ThemePreviewCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark;
return RepaintBoundary(
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
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),
),
),
),
child: LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final previewHeight = (cardWidth * 0.56).clamp(170.0, 220.0);
final innerWidth = (cardWidth - 48).clamp(220.0, 320.0);
final innerHeight = (previewHeight * 0.70).clamp(120.0, 160.0);
final innerPadding = (innerHeight * 0.11).clamp(12.0, 18.0);
final artworkSize = (innerHeight - (innerPadding * 2)).clamp(
80.0,
120.0,
);
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,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
width: 108,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
return Container(
constraints: BoxConstraints(minHeight: previewHeight),
width: double.infinity,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned(
top: -(previewHeight * 0.25),
right: -(previewHeight * 0.25),
child: Container(
width: previewHeight,
height: previewHeight,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.primaryContainer.withValues(
alpha: 0.5,
),
),
const SizedBox(width: 16),
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),
),
),
),
Positioned(
bottom: -(previewHeight * 0.15),
left: -(previewHeight * 0.15),
child: Container(
width: previewHeight * 0.75,
height: previewHeight * 0.75,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: colorScheme.tertiaryContainer.withValues(
alpha: 0.5,
),
),
),
),
Center(
child: Container(
width: innerWidth,
height: innerHeight,
padding: EdgeInsets.all(innerPadding),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 12,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
width: artworkSize,
height: artworkSize,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
const SizedBox(height: 8),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: artworkSize * 0.44,
),
const SizedBox(height: 24),
Row(
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.skip_previous,
size: 24,
color: colorScheme.onSurfaceVariant,
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Icon(
Icons.play_circle_fill,
size: 32,
color: colorScheme.primary,
const SizedBox(height: 8),
Container(
width: 80,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 12),
Icon(
Icons.skip_next,
size: 24,
color: colorScheme.onSurfaceVariant,
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,
),
],
),
],
),
],
),
),
],
),
],
),
),
),
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 ? context.l10n.appearanceThemeDark : context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
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
? context.l10n.appearanceThemeDark
: context.l10n.appearanceThemeLight,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
],
),
);
},
),
);
}
@ -498,7 +514,7 @@ class _ThemeModeChip extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.05),
@ -694,7 +710,7 @@ class _LanguageSelector extends StatelessWidget {
required this.onChanged,
});
static const _allLanguages = [
static const _allLanguages = [
('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language),
@ -735,16 +751,10 @@ static const _allLanguages = [
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Icon(
Icons.language,
color: colorScheme.onSurfaceVariant,
),
leading: Icon(Icons.language, color: colorScheme.onSurfaceVariant),
title: Text(context.l10n.appearanceLanguage),
subtitle: Text(_getLanguageName(currentLocale)),
trailing: Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
onTap: () => _showLanguagePicker(context),
);
}
@ -765,9 +775,9 @@ static const _allLanguages = [
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.appearanceLanguage,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
const Divider(height: 1),
@ -781,22 +791,22 @@ static const _allLanguages = [
return ListTile(
leading: Icon(
lang.$3,
color: isSelected
? colorScheme.primary
color: isSelected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
title: Text(
lang.$2,
style: TextStyle(
color: isSelected
? colorScheme.primary
color: isSelected
? colorScheme.primary
: colorScheme.onSurface,
fontWeight: isSelected
? FontWeight.w600
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
),
),
trailing: isSelected
trailing: isSelected
? Icon(Icons.check, color: colorScheme.primary)
: null,
onTap: () {

View file

@ -0,0 +1,675 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class CacheManagementPage extends ConsumerStatefulWidget {
const CacheManagementPage({super.key});
@override
ConsumerState<CacheManagementPage> createState() =>
_CacheManagementPageState();
}
class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
// Keep in sync with ExploreNotifier keys.
static const String _exploreCacheKey = 'explore_home_feed_cache';
static const String _exploreCacheTsKey = 'explore_home_feed_ts';
_CacheOverview? _overview;
bool _isLoading = true;
String? _busyAction;
@override
void initState() {
super.initState();
_refreshOverview();
}
bool get _isBusy => _busyAction != null;
Future<void> _refreshOverview() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final overview = await _buildOverview();
if (!mounted) return;
setState(() {
_overview = overview;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
Future<_CacheOverview> _buildOverview() async {
final appCacheDir = await getApplicationCacheDirectory();
final tempDir = await getTemporaryDirectory();
final appCachePath = p.normalize(appCacheDir.path);
final tempPath = p.normalize(tempDir.path);
final tempIsSameAsAppCache = appCachePath == tempPath;
final appCacheStats = await _scanDirectory(Directory(appCachePath));
final tempStats = tempIsSameAsAppCache
? null
: await _scanDirectory(Directory(tempPath));
final coverStats = await CoverCacheManager.getStats();
final prefs = await SharedPreferences.getInstance();
final explorePayload = prefs.getString(_exploreCacheKey);
final exploreTs = prefs.getInt(_exploreCacheTsKey);
var exploreBytes = 0;
if (explorePayload != null && explorePayload.isNotEmpty) {
exploreBytes += utf8.encode(explorePayload).length;
}
if (exploreTs != null) {
exploreBytes += 8;
}
final hasExploreCache = exploreBytes > 0;
int trackCacheEntries;
try {
trackCacheEntries = await PlatformBridge.getTrackCacheSize();
} catch (_) {
trackCacheEntries = 0;
}
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
final libraryCoverStats = await _scanDirectory(libraryCoverDir);
return _CacheOverview(
appCachePath: appCachePath,
appCacheStats: appCacheStats,
tempPath: tempIsSameAsAppCache ? null : tempPath,
tempStats: tempStats,
tempIsSameAsAppCache: tempIsSameAsAppCache,
coverStats: coverStats,
libraryCoverStats: libraryCoverStats,
exploreCacheBytes: exploreBytes,
hasExploreCache: hasExploreCache,
trackCacheEntries: trackCacheEntries,
);
}
Future<_DirectoryStats> _scanDirectory(Directory directory) async {
if (!await directory.exists()) {
return const _DirectoryStats(fileCount: 0, totalSizeBytes: 0);
}
var fileCount = 0;
var totalSize = 0;
try {
await for (final entity in directory.list(
recursive: true,
followLinks: false,
)) {
if (entity is File) {
fileCount++;
totalSize += await entity.length();
}
}
} catch (_) {}
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
}
Future<void> _clearDirectoryContents(String path) async {
final directory = Directory(path);
if (!await directory.exists()) return;
try {
final entities = directory.listSync(followLinks: false);
for (final entity in entities) {
try {
await entity.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
try {
await directory.create(recursive: true);
} catch (_) {}
}
Future<void> _clearAppCache() async {
final cacheDir = await getApplicationCacheDirectory();
await _clearDirectoryContents(cacheDir.path);
}
Future<void> _clearTempCache() async {
final tempDir = await getTemporaryDirectory();
await _clearDirectoryContents(tempDir.path);
}
Future<void> _clearCoverCache() async {
await CoverCacheManager.clearCache();
}
Future<void> _clearLibraryCoverCache() async {
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
await _clearDirectoryContents(libraryCoverDir.path);
}
Future<void> _clearExploreCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_exploreCacheKey);
await prefs.remove(_exploreCacheTsKey);
}
Future<void> _clearTrackCache() async {
await PlatformBridge.clearTrackCache();
}
Future<void> _clearAllCaches() async {
final currentOverview = _overview;
await _clearAppCache();
if (currentOverview != null && !currentOverview.tempIsSameAsAppCache) {
await _clearTempCache();
}
await _clearCoverCache();
await _clearLibraryCoverCache();
await _clearExploreCache();
await _clearTrackCache();
}
Future<bool> _confirmClear(String target) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.cacheClearConfirmTitle),
content: Text(context.l10n.cacheClearConfirmMessage(target)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogClear),
),
],
),
);
return confirm == true;
}
Future<bool> _confirmClearAll() async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.cacheClearAllConfirmTitle),
content: Text(context.l10n.cacheClearAllConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogClear),
),
],
),
);
return confirm == true;
}
Future<void> _runAction(
String actionKey,
Future<void> Function() action, {
String? successMessage,
}) async {
if (_isBusy || !mounted) return;
setState(() => _busyAction = actionKey);
try {
await action();
if (!mounted) return;
if (successMessage != null && successMessage.isNotEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(successMessage)));
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
} finally {
if (mounted) {
setState(() => _busyAction = null);
await _refreshOverview();
}
}
}
Future<void> _confirmAndRunAction({
required String actionKey,
required String targetLabel,
required Future<void> Function() action,
}) async {
final confirmed = await _confirmClear(targetLabel);
if (!confirmed) return;
if (!mounted) return;
await _runAction(
actionKey,
action,
successMessage: context.l10n.cacheClearSuccess(targetLabel),
);
}
Future<void> _cleanupUnusedData() async {
await _runAction('cleanup_unused', () async {
final orphanedDownloads = await ref
.read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads();
final missingLibraryEntries = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.cacheCleanupResult(
orphanedDownloads,
missingLibraryEntries,
),
),
),
);
});
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
String _formatDirectorySize(_DirectoryStats stats) {
if (stats.fileCount == 0 || stats.totalSizeBytes == 0) {
return context.l10n.cacheNoData;
}
return context.l10n.cacheSizeWithFiles(
_formatBytes(stats.totalSizeBytes),
stats.fileCount,
);
}
String _buildSubtitle(String description, String sizeInfo) {
return '$description\n$sizeInfo';
}
Widget _buildClearTrailing(String actionKey, VoidCallback onPressed) {
if (_busyAction == actionKey) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
return TextButton(
onPressed: _isBusy ? null : onPressed,
child: Text(context.l10n.dialogClear),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final overview = _overview;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight,
floating: false,
pinned: true,
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
actions: [
IconButton(
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
),
],
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding;
final expandRatio =
((constraints.maxHeight - minHeight) /
(maxHeight - minHeight))
.clamp(0.0, 1.0);
final leftPadding = 56 - (32 * expandRatio);
return FlexibleSpaceBar(
expandedTitleScale: 1.0,
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
title: Text(
context.l10n.cacheTitle,
style: TextStyle(
fontSize: 20 + (8 * expandRatio),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
);
},
),
),
if (_isLoading || overview == null)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else ...[
SliverToBoxAdapter(
child: Container(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.cacheSummaryTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 6),
Text(
context.l10n.cacheEstimatedTotal(
_formatBytes(overview.totalKnownDiskCacheBytes),
),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 2),
Text(
context.l10n.cacheSummarySubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.85,
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _isBusy
? null
: () async {
final l10n = context.l10n;
final confirmed = await _confirmClearAll();
if (!confirmed) return;
if (!mounted) return;
await _runAction(
'clear_all',
_clearAllCaches,
successMessage: l10n.cacheClearSuccess(
l10n.cacheClearAll,
),
);
},
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(context.l10n.cacheClearAll),
),
OutlinedButton.icon(
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
label: Text(context.l10n.cacheRefreshStats),
),
],
),
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.cacheSectionStorage,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.cacheAppDirectory,
subtitle: _buildSubtitle(
context.l10n.cacheAppDirectoryDesc,
_formatDirectorySize(overview.appCacheStats),
),
trailing: _buildClearTrailing(
'clear_app_cache',
() => _confirmAndRunAction(
actionKey: 'clear_app_cache',
targetLabel: context.l10n.cacheAppDirectory,
action: _clearAppCache,
),
),
),
if (!overview.tempIsSameAsAppCache &&
overview.tempStats != null)
SettingsItem(
icon: Icons.timer_outlined,
title: context.l10n.cacheTempDirectory,
subtitle: _buildSubtitle(
context.l10n.cacheTempDirectoryDesc,
_formatDirectorySize(overview.tempStats!),
),
trailing: _buildClearTrailing(
'clear_temp_cache',
() => _confirmAndRunAction(
actionKey: 'clear_temp_cache',
targetLabel: context.l10n.cacheTempDirectory,
action: _clearTempCache,
),
),
),
SettingsItem(
icon: Icons.image_outlined,
title: context.l10n.cacheCoverImage,
subtitle: _buildSubtitle(
context.l10n.cacheCoverImageDesc,
overview.coverStats.fileCount > 0 &&
overview.coverStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
_formatBytes(overview.coverStats.totalSizeBytes),
overview.coverStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_cover_cache',
() => _confirmAndRunAction(
actionKey: 'clear_cover_cache',
targetLabel: context.l10n.cacheCoverImage,
action: _clearCoverCache,
),
),
),
SettingsItem(
icon: Icons.library_music_outlined,
title: context.l10n.cacheLibraryCover,
subtitle: _buildSubtitle(
context.l10n.cacheLibraryCoverDesc,
overview.libraryCoverStats.fileCount > 0 &&
overview.libraryCoverStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
_formatBytes(
overview.libraryCoverStats.totalSizeBytes,
),
overview.libraryCoverStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_library_cover_cache',
() => _confirmAndRunAction(
actionKey: 'clear_library_cover_cache',
targetLabel: context.l10n.cacheLibraryCover,
action: _clearLibraryCoverCache,
),
),
),
SettingsItem(
icon: Icons.explore_outlined,
title: context.l10n.cacheExploreFeed,
subtitle: _buildSubtitle(
context.l10n.cacheExploreFeedDesc,
overview.hasExploreCache
? context.l10n.cacheSizeOnly(
_formatBytes(overview.exploreCacheBytes),
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_explore_cache',
() => _confirmAndRunAction(
actionKey: 'clear_explore_cache',
targetLabel: context.l10n.cacheExploreFeed,
action: _clearExploreCache,
),
),
),
SettingsItem(
icon: Icons.memory_outlined,
title: context.l10n.cacheTrackLookup,
subtitle: _buildSubtitle(
context.l10n.cacheTrackLookupDesc,
overview.trackCacheEntries > 0
? context.l10n.cacheEntries(overview.trackCacheEntries)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_track_cache',
() => _confirmAndRunAction(
actionKey: 'clear_track_cache',
targetLabel: context.l10n.cacheTrackLookup,
action: _clearTrackCache,
),
),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.cacheSectionMaintenance,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.cacheCleanupUnused,
subtitle: '${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
trailing: _buildClearTrailing(
'cleanup_unused',
_cleanupUnusedData,
),
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
],
),
);
}
}
class _CacheOverview {
final String appCachePath;
final _DirectoryStats appCacheStats;
final String? tempPath;
final _DirectoryStats? tempStats;
final bool tempIsSameAsAppCache;
final CacheStats coverStats;
final _DirectoryStats libraryCoverStats;
final int exploreCacheBytes;
final bool hasExploreCache;
final int trackCacheEntries;
const _CacheOverview({
required this.appCachePath,
required this.appCacheStats,
this.tempPath,
this.tempStats,
required this.tempIsSameAsAppCache,
required this.coverStats,
required this.libraryCoverStats,
required this.exploreCacheBytes,
required this.hasExploreCache,
required this.trackCacheEntries,
});
int get totalKnownDiskCacheBytes {
return appCacheStats.totalSizeBytes +
(tempStats?.totalSizeBytes ?? 0) +
coverStats.totalSizeBytes +
libraryCoverStats.totalSizeBytes +
exploreCacheBytes;
}
}
class _DirectoryStats {
final int fileCount;
final int totalSizeBytes;
const _DirectoryStats({
required this.fileCount,
required this.totalSizeBytes,
});
}

View file

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/donate_icons.dart';
class DonatePage extends StatelessWidget {
@ -9,7 +10,7 @@ class DonatePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: CustomScrollView(
@ -199,6 +200,8 @@ class _RecentDonorsCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
_DonorTile(name: 'J', colorScheme: colorScheme),
_DonorTile(name: 'Julian', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile(
name: '283Fabio',

View file

@ -9,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerStatefulWidget {
@ -93,7 +94,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
@ -346,7 +347,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ref,
settings.folderOrganization,
),
showDivider: false,
),
SettingsSwitchItem(
icon: Icons.person_search_outlined,
title: context.l10n.downloadUseAlbumArtistForFolders,
subtitle: settings.useAlbumArtistForFolders
? context
.l10n
.downloadUseAlbumArtistForFoldersAlbumSubtitle
: context
.l10n
.downloadUseAlbumArtistForFoldersTrackSubtitle,
value: settings.useAlbumArtistForFolders,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUseAlbumArtistForFolders(value),
showDivider: false,
),
],
),

View file

@ -6,6 +6,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget {
@ -55,7 +56,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final hasError = extension.status == 'error';
return PopScope(

View file

@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionsPage extends ConsumerStatefulWidget {
@ -51,7 +52,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true, // Always allow back gesture

View file

@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget {
@ -30,7 +31,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
// -> /storage/emulated/0/Music
try {
final uri = Uri.parse(path);
final treePath = uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final treePath =
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
@ -156,10 +158,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return;
}
await ref.read(localLibraryProvider.notifier).startScan(
libraryPath,
forceFullScan: forceFullScan,
);
await ref
.read(localLibraryProvider.notifier)
.startScan(libraryPath, forceFullScan: forceFullScan);
}
Future<void> _cancelScan() async {
@ -216,7 +217,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final settings = ref.watch(settingsProvider);
final libraryState = ref.watch(localLibraryProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: CustomScrollView(
@ -260,6 +261,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter(
child: _LibraryHeroCard(
itemCount: libraryState.items.length,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning,
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
@ -331,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6),
color: colorScheme.tertiaryContainer.withValues(
alpha: 0.6,
),
borderRadius: BorderRadius.circular(12),
),
child: Row(
@ -347,17 +351,20 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
children: [
Text(
'Scan cancelled',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
),
const SizedBox(height: 2),
Text(
'You can retry the scan when ready.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8),
),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.8),
),
),
],
),
@ -493,6 +500,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
class _LibraryHeroCard extends StatelessWidget {
final int itemCount;
final int excludedDownloadedCount;
final bool isScanning;
final double scanProgress;
final String? scanCurrentFile;
@ -502,6 +510,7 @@ class _LibraryHeroCard extends StatelessWidget {
const _LibraryHeroCard({
required this.itemCount,
required this.excludedDownloadedCount,
required this.isScanning,
required this.scanProgress,
this.scanCurrentFile,
@ -527,10 +536,13 @@ class _LibraryHeroCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final displayCount = isScanning
? scannedFiles
: itemCount + excludedDownloadedCount;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 220,
constraints: const BoxConstraints(minHeight: 220),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28),
@ -626,12 +638,12 @@ class _LibraryHeroCard extends StatelessWidget {
),
],
),
const Spacer(),
const SizedBox(height: 16),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
isScanning ? scannedFiles.toString() : itemCount.toString(),
displayCount.toString(),
style: TextStyle(
fontSize: 48,
fontWeight: FontWeight.bold,
@ -644,17 +656,35 @@ class _LibraryHeroCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
isScanning
? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim()
? context.l10n
.libraryTracksCount(scannedFiles)
.replaceAll(scannedFiles.toString(), '')
.trim()
: context.l10n
.libraryTracksCount(itemCount)
.replaceAll(itemCount.toString(), '')
.trim(),
.libraryTracksCount(displayCount)
.replaceAll(displayCount.toString(), '')
.trim(),
style: TextStyle(
fontSize: 16,
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
if (!isScanning && excludedDownloadedCount > 0) ...[
const SizedBox(height: 4),
Text(
'$excludedDownloadedCount from Downloads history '
'(excluded from list)',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.8,
),
),
),
],
if (isScanning && scanCurrentFile != null) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
@ -670,7 +700,9 @@ class _LibraryHeroCard extends StatelessWidget {
Icon(
Icons.history,
size: 14,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
const SizedBox(width: 6),
Text(
@ -679,7 +711,9 @@ class _LibraryHeroCard extends StatelessWidget {
),
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
),
],

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@ -126,7 +127,7 @@ class _LogScreenState extends State<LogScreen> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
final logs = _filteredLogs;
return PopScope(

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
const MetadataProviderPriorityPage({super.key});
@ -40,7 +41,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: !_hasChanges,

View file

@ -5,6 +5,7 @@ import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class OptionsSettingsPage extends ConsumerWidget {
@ -16,7 +17,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final extensionState = ref.watch(extensionProvider);
final hasExtensions = extensionState.extensions.isNotEmpty;
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: true, // Always allow back gesture
@ -958,6 +959,27 @@ class _MetadataSourceSelector extends ConsumerWidget {
],
),
],
if (currentSource == 'spotify' && !hasExtensionSearch) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 16,
color: colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.optionsSpotifyDeprecationWarning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
),
],
),
],
],
),
);

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key});
@ -40,7 +41,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return PopScope(
canPop: !_hasChanges,

View file

@ -8,8 +8,10 @@ import 'package:spotiflac_android/screens/settings/extensions_page.dart';
import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
import 'package:spotiflac_android/screens/settings/about_page.dart';
import 'package:spotiflac_android/screens/settings/cache_management_page.dart';
import 'package:spotiflac_android/screens/settings/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget {
@ -18,7 +20,7 @@ class SettingsTab extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView(
slivers: [
@ -73,19 +75,29 @@ class SettingsTab extends ConsumerWidget {
icon: Icons.download_outlined,
title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()),
onTap: () =>
_navigateTo(context, const DownloadSettingsPage()),
),
SettingsItem(
icon: Icons.library_music_outlined,
title: l10n.settingsLocalLibrary,
subtitle: l10n.settingsLocalLibrarySubtitle,
onTap: () => _navigateTo(context, const LibrarySettingsPage()),
onTap: () =>
_navigateTo(context, const LibrarySettingsPage()),
),
SettingsItem(
icon: Icons.storage_outlined,
title: l10n.settingsCache,
subtitle: l10n.settingsCacheSubtitle,
onTap: () =>
_navigateTo(context, const CacheManagementPage()),
),
SettingsItem(
icon: Icons.tune_outlined,
title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()),
onTap: () =>
_navigateTo(context, const OptionsSettingsPage()),
),
SettingsItem(
icon: Icons.extension_outlined,
@ -138,7 +150,7 @@ class SettingsTab extends ConsumerWidget {
void _navigateTo(BuildContext context, Widget page) {
FocusManager.instance.primaryFocus?.unfocus();
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
@ -146,9 +158,10 @@ class SettingsTab extends ConsumerWidget {
const begin = Offset(1.0, 0.0);
const end = Offset.zero;
const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var tween = Tween(
begin: begin,
end: end,
).chain(CurveTween(curve: curve));
return SlideTransition(
position: animation.drive(tween),
child: child,

View file

@ -248,7 +248,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
context: context,
builder: (context) => AlertDialog(
title: Text(context.l10n.setupUseDefaultFolder),
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'),
content: Text(
'${context.l10n.setupNoFolderSelected}\n\n$defaultDir',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@ -576,37 +578,60 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
Widget _buildWelcomeStep(ColorScheme colorScheme) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo-transparant.png',
width: 104,
height: 104,
color: colorScheme.primary,
fit: BoxFit.contain,
),
const SizedBox(height: 32),
Text(
context.l10n.appName,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
return LayoutBuilder(
builder: (context, constraints) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final logoSize = (shortestSide * 0.24).clamp(80.0, 104.0);
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
final subtitleGap = (shortestSide * 0.04).clamp(8.0, 16.0);
final minContentHeight = constraints.maxHeight > 48
? constraints.maxHeight - 48
: 0.0;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minContentHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo-transparant.png',
width: logoSize,
height: logoSize,
color: colorScheme.primary,
fit: BoxFit.contain,
),
SizedBox(height: titleGap),
Text(
context.l10n.appName,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
fontSize:
(Theme.of(context).textTheme.displaySmall?.fontSize ??
36) *
(1 + ((textScale - 1) * 0.18)),
),
),
SizedBox(height: subtitleGap),
Text(
context.l10n.setupDownloadInFlac,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
),
const SizedBox(height: 16),
Text(
context.l10n.setupDownloadInFlac,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
),
);
},
);
}
@ -833,41 +858,58 @@ class _StepLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
return LayoutBuilder(
builder: (context, constraints) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0);
final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0);
final minContentHeight = constraints.maxHeight > 48
? constraints.maxHeight - 48
: 0.0;
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minContentHeight),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
),
child: Icon(icon, size: iconSize, color: colorScheme.primary),
),
SizedBox(height: titleGap),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
SizedBox(height: descriptionGap),
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
textAlign: TextAlign.center,
),
SizedBox(height: actionGap),
child,
],
),
child: Icon(icon, size: 48, color: colorScheme.primary),
),
const SizedBox(height: 32),
Text(
title,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
child,
],
),
);
},
);
}
}
@ -881,21 +923,25 @@ class _SuccessCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 12),
Text(
text,
style: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
Expanded(
child: Text(
text,
style: TextStyle(
fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],

View file

@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key});
@ -44,7 +45,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Widget build(BuildContext context) {
final state = ref.watch(storeProvider);
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return Scaffold(
body: RefreshIndicator(

View file

@ -16,6 +16,26 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
int _currentPage = 0;
static const int _totalPages = 6;
double _responsiveScale({
required BuildContext context,
double min = 0.82,
double max = 1.08,
double baseShortestSide = 390,
}) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = shortestSide / baseShortestSide;
if (scale < min) return min;
if (scale > max) return max;
return scale;
}
double _effectiveTextScale(BuildContext context) {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
@override
void dispose() {
_pageController.dispose();
@ -55,6 +75,15 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
final colorScheme = Theme.of(context).colorScheme;
final l10n = context.l10n;
final isLastPage = _currentPage == _totalPages - 1;
final scale = _responsiveScale(context: context, min: 0.86, max: 1.05);
final textScale = _effectiveTextScale(context);
final topBarPaddingH = 24 * scale;
final topBarPaddingV = 16 * scale;
final pageIndicatorHeight = 8 * scale;
final pageIndicatorWidth = 8 * scale;
final activeIndicatorWidth = 32 * scale;
final bottomGap = (32 * scale) + ((textScale - 1) * 8);
final actionButtonHeight = (56 * scale) + ((textScale - 1) * 6);
return Scaffold(
backgroundColor: colorScheme.surface,
@ -63,7 +92,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
children: [
// Top Navigation Bar
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
vertical: topBarPaddingV,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -199,9 +231,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack,
margin: const EdgeInsets.symmetric(horizontal: 4),
height: 8,
width: isActive ? 32 : 8,
margin: EdgeInsets.symmetric(horizontal: 4 * scale),
height: pageIndicatorHeight,
width: isActive
? activeIndicatorWidth
: pageIndicatorWidth,
decoration: BoxDecoration(
color: isActive
? colorScheme.primary
@ -211,11 +245,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
);
}),
),
const SizedBox(height: 32),
SizedBox(height: bottomGap),
// Action Button
SizedBox(
width: double.infinity,
height: 56,
height: actionButtonHeight,
child: FilledButton(
onPressed: _nextPage,
style: FilledButton.styleFrom(
@ -520,104 +554,114 @@ class _InteractiveDownloadExampleState
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Row(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: 36,
color: colorScheme.onPrimaryContainer,
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 140,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(7),
),
child: Row(
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(height: 10),
if (_isDownloading)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: _progress,
minHeight: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
else
Container(
width: 90,
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (_isCompleted ? Colors.green : colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: _isDownloading
? SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.onPrimary,
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: (cardWidth * 0.35).clamp(100.0, 160.0),
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(7),
),
)
: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: 28,
),
),
const SizedBox(height: 10),
if (_isDownloading)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: _progress,
minHeight: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
else
Container(
width: (cardWidth * 0.22).clamp(70.0, 100.0),
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _startDownload,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
padding: EdgeInsets.all(buttonPadding),
decoration: BoxDecoration(
color: _isCompleted ? Colors.green : colorScheme.primary,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color:
(_isCompleted ? Colors.green : colorScheme.primary)
.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: _isDownloading
? SizedBox(
width: buttonIconSize,
height: buttonIconSize,
child: CircularProgressIndicator(
strokeWidth: 3,
color: colorScheme.onPrimary,
),
)
: Icon(
_isCompleted
? Icons.check_rounded
: Icons.download_rounded,
color: colorScheme.onPrimary,
size: buttonIconSize,
),
),
),
],
),
],
),
);
},
);
}
}
@ -644,6 +688,18 @@ class _TutorialPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final scale = (shortestSide / 390).clamp(0.86, 1.05);
final topGap = (24 * scale).clamp(16.0, 24.0);
final iconPadding = (24 * scale).clamp(18.0, 24.0);
final iconSize = (56 * scale).clamp(44.0, 56.0);
final iconTextGap = (48 * scale).clamp(28.0, 48.0);
final descriptionGap = (20 * scale).clamp(12.0, 20.0);
final contentGap = (56 * scale) + ((textScale - 1) * 10);
final bottomGap = (32 * scale).clamp(20.0, 32.0);
// Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable
@ -656,23 +712,23 @@ class _TutorialPage extends StatelessWidget {
physics: const BouncingScrollPhysics(),
child: Column(
children: [
const SizedBox(height: 24),
SizedBox(height: topGap),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack,
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
padding: const EdgeInsets.all(24),
padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration(
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 56,
size: iconSize,
color: iconColor ?? colorScheme.primary,
),
),
const SizedBox(height: 48),
SizedBox(height: iconTextGap),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
@ -687,7 +743,7 @@ class _TutorialPage extends StatelessWidget {
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
SizedBox(height: descriptionGap),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
@ -697,14 +753,14 @@ class _TutorialPage extends StatelessWidget {
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
fontSize: 16,
fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 56),
SizedBox(height: contentGap),
content, // The content itself now handles its own internal animations
const SizedBox(height: 32),
SizedBox(height: bottomGap),
],
),
);

View file

@ -11,6 +11,7 @@ final _log = AppLogger('FFmpeg');
class FFmpegService {
static const int _commandLogPreviewLength = 300;
static int _tempEmbedCounter = 0;
static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
@ -47,6 +48,14 @@ class FFmpegService {
return '${redacted.substring(0, _commandLogPreviewLength)}...';
}
static String _nextTempEmbedPath(String tempDirPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
_tempEmbedCounter = (_tempEmbedCounter + 1) & 0x7fffffff;
final timestamp = DateTime.now().microsecondsSinceEpoch;
final processId = pid;
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
}
static Future<FFmpegResult> _execute(String command) async {
try {
final session = await FFmpegKit.execute(command);
@ -269,8 +278,7 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" ');
@ -347,8 +355,7 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" ');
@ -429,8 +436,7 @@ class FFmpegService {
Map<String, String>? metadata,
}) async {
final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch;
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$opusPath" ');

View file

@ -0,0 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
const double kNormalizedHeaderTopPadding = 24.0;
double normalizedHeaderTopPadding(
BuildContext context, {
double max = kNormalizedHeaderTopPadding,
}) {
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
return 0;
}
final topPadding = MediaQuery.paddingOf(context).top;
if (topPadding <= 0) return 0;
return topPadding > max ? max : topPadding;
}

View file

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// A collapsing header widget
/// Title collapses from large to small when scrolling
@ -19,7 +20,7 @@ class CollapsingHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top;
final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView(
slivers: [