diff --git a/CHANGELOG.md b/CHANGELOG.md index 0920d715..982bc103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/go_backend/matching_test.go b/go_backend/matching_test.go deleted file mode 100644 index f22028e1..00000000 --- a/go_backend/matching_test.go +++ /dev/null @@ -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") - } - }) -} diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 1632a59c..a74a1ea1 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -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 } } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 6140d9e9..cb6c9f5f 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -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 diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 676d2d51..314d70dd 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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 diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d4e0f57e..3ba4767d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6cb9f955..09f1a73a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 3696f953..f50225e8 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -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`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index d5f52535..4a072dd7 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index c3c23c5b..6dbd5e13 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 13ac77ba..3d622818 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 85387ac7..d4ed6e5e 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index cd5596eb..46e5cbf2 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index d1e316df..47502e33 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4d595291..02bbd0dd 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -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`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 5ce64da2..58d935a1 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 5e8d3fa9..b6d0b136 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -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'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4e6c31ef..a2ae85f8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -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`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4f365b51..45939a0f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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"} } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index c92fd165..d607e6fe 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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"} +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 45aa96dc..fac29ac8 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -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, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 648104cf..9805762e 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson(AppSettings instance) => 'updateChannel': instance.updateChannel, 'hasSearchedBefore': instance.hasSearchedBefore, 'folderOrganization': instance.folderOrganization, + 'useAlbumArtistForFolders': instance.useAlbumArtistForFolders, 'historyViewMode': instance.historyViewMode, 'historyFilterMode': instance.historyFilterMode, 'askQualityBeforeDownload': instance.askQualityBeforeDownload, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 8143d9c6..a597c842 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1027,14 +1027,16 @@ class DownloadQueueNotifier extends Notifier { 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 { 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 { 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 { 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 { 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 { 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 { 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 { settings.folderOrganization, separateSingles: settings.separateSingles, albumFolderStructure: settings.albumFolderStructure, + useAlbumArtistForFolders: settings.useAlbumArtistForFolders, ) : ''; String? appOutputDir; @@ -2540,6 +2548,7 @@ class DownloadQueueNotifier extends Notifier { 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 { settings.folderOrganization, separateSingles: settings.separateSingles, albumFolderStructure: settings.albumFolderStructure, + useAlbumArtistForFolders: settings.useAlbumArtistForFolders, ); final fallbackResult = await runDownload( useSaf: false, diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 97da7ef9..a42f2b17 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -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 items; @@ -22,6 +23,7 @@ class LocalLibraryState { final int scanErrorCount; final bool scanWasCancelled; final DateTime? lastScannedAt; + final int excludedDownloadedCount; final Set _isrcSet; final Set _trackKeySet; final Map _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 { 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 { ); 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 { 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 { 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 { 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 { 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 { try { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_lastScannedAtKey); + await prefs.remove(_excludedDownloadedCountKey); } catch (e) { _log.w('Failed to clear lastScannedAt: $e'); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index b00037eb..7a572567 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -226,6 +226,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setUseAlbumArtistForFolders(bool enabled) { + state = state.copyWith(useAlbumArtistForFolders: enabled); + _saveSettings(); + } + void setHistoryViewMode(String mode) { state = state.copyWith(historyViewMode: mode); _saveSettings(); diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 65f3b9a7..6e2229ac 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -233,11 +233,17 @@ class _AlbumScreenState extends ConsumerState { } 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 { 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 { left: 0, right: 0, bottom: 0, - height: 80, + height: bottomGradientHeight, child: Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -311,7 +318,7 @@ class _AlbumScreenState extends ConsumerState { 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 { color: colorScheme.surfaceContainerHighest, child: Icon( Icons.album, - size: 64, + size: fallbackIconSize, color: colorScheme.onSurfaceVariant, ), ), diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 55cc7d91..f007ab8f 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -113,6 +113,37 @@ class _ArtistScreenState extends ConsumerState { List _singlesBucket = const []; List _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 { List albums, ColorScheme colorScheme, ) { + final sectionHeight = _artistAlbumSectionHeight(); + final tileSize = _artistAlbumTileSize(); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1425,7 +1459,7 @@ class _ArtistScreenState extends ConsumerState { ), ), 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 { 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 { ); } - 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 { } }, 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 { 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 { ), ) : Container( - width: 140, - height: 140, + width: tileSize, + height: tileSize, color: colorScheme.surfaceContainerHighest, child: Icon( Icons.album, @@ -1553,26 +1593,36 @@ class _ArtistScreenState extends ConsumerState { ], ), 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, ), ], ), diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 6f93fa8d..28ff7e2f 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -23,7 +23,8 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _DownloadedAlbumScreenState(); + ConsumerState createState() => + _DownloadedAlbumScreenState(); } class _DownloadedAlbumScreenState extends ConsumerState { @@ -53,27 +54,31 @@ class _DownloadedAlbumScreenState extends ConsumerState { } /// Get tracks for this album from history provider (reactive) - List _getAlbumTracks(List allItems) { + List _getAlbumTracks( + List 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> _groupTracksByDisc( @@ -147,7 +152,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { 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 { 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 { } 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 { 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 { 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 { _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 { } 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 { ), 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 { 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 { 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 { 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 { ), ], ), - 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 { ); } - Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List tracks) { + Widget _buildInfoCard( + BuildContext context, + ColorScheme colorScheme, + List 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 { 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 { return firstQuality; } - Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List tracks) { + Widget _buildTrackListHeader( + BuildContext context, + ColorScheme colorScheme, + List tracks, + ) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), @@ -470,14 +555,24 @@ class _DownloadedAlbumScreenState extends ConsumerState { 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 { ); } - Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { + Widget _buildTrackList( + BuildContext context, + ColorScheme colorScheme, + List 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 { } } - 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 { 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 { ); } - 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 { 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 { 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 { 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 tracks, double bottomPadding) { + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List 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 { 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 { _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 { 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), + ), ), ), ), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 0e22b803..b3cd0dd0 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -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 Set? _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 } void _onSearchFocusChanged() { + if (mounted) { + setState(() {}); + } if (_searchFocusNode.hasFocus) { ref.read(trackProvider.notifier).setShowingRecentAccess(true); } @@ -324,6 +372,7 @@ class _HomeTabState extends ConsumerState 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 albumName: trackState.albumName!, coverUrl: trackState.coverUrl, tracks: trackState.tracks, + extensionId: extensionId, ), ), ); @@ -348,7 +398,7 @@ class _HomeTabState extends ConsumerState 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 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 artistName: trackState.artistName!, coverUrl: trackState.coverUrl, albums: trackState.artistAlbums!, + extensionId: extensionId, ), ), ); @@ -586,7 +638,9 @@ class _HomeTabState extends ConsumerState 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 (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 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 ]; } - 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 ), // 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 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 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 ), ), SizedBox( - height: 130, + height: rowHeight, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: itemCount, @@ -991,7 +1054,7 @@ class _HomeTabState extends ConsumerState 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 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 } 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 ), ), 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 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 : 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 ], ), 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 ), 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), + ), + ], ], ), ), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index d7313fc4..e9cbcdaf 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -89,7 +89,9 @@ class _LocalAlbumScreenState extends ConsumerState { _hasMultipleDiscsCache = _discGroupsCache.length > 1; } - Map> _groupTracksByDisc(List tracks) { + Map> _groupTracksByDisc( + List tracks, + ) { final discMap = >{}; for (final track in tracks) { final discNumber = track.discNumber ?? 1; @@ -158,7 +160,7 @@ class _LocalAlbumScreenState extends ConsumerState { 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 { 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 { } 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 { 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 { _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 { } 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 { ), 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 { 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 { 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 { 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 { ), ], ), - 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 { ); } - Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List tracks) { + Widget _buildInfoCard( + BuildContext context, + ColorScheme colorScheme, + List 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 { 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 { // 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 { 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 tracks) { + Widget _buildTrackListHeader( + BuildContext context, + ColorScheme colorScheme, + List tracks, + ) { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), @@ -485,14 +580,24 @@ class _LocalAlbumScreenState extends ConsumerState { 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 { ); } - Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List tracks) { + Widget _buildTrackList( + BuildContext context, + ColorScheme colorScheme, + List tracks, + ) { final discGroups = _discGroupsCache; final hasMultipleDiscs = _hasMultipleDiscsCache; - + final slivers = []; - + for (final discNumber in _sortedDiscNumbersCache) { final discTracks = discGroups[discNumber]!; - + if (hasMultipleDiscs) { slivers.add( SliverToBoxAdapter( @@ -517,7 +626,10 @@ class _LocalAlbumScreenState extends ConsumerState { 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 { 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 { ), ); } - + 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 { 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 { 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 { ), ), 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 tracks, double bottomPadding) { + Widget _buildSelectionBottomBar( + BuildContext context, + ColorScheme colorScheme, + List tracks, + double bottomPadding, + ) { final selectedCount = _selectedIds.length; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; @@ -694,12 +853,18 @@ class _LocalAlbumScreenState extends ConsumerState { 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 { _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 { 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), + ), ), ), ), diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 1d89264b..8d7b96ca 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -390,7 +390,9 @@ class _MainShellState extends ConsumerState { 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 { } } +/// 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}); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 4824baec..933fdd2c 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -145,11 +145,17 @@ class _PlaylistScreenState extends ConsumerState { } 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 { 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 { left: 0, right: 0, bottom: 0, - height: 80, + height: bottomGradientHeight, child: Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -225,7 +232,7 @@ class _PlaylistScreenState extends ConsumerState { 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 { color: colorScheme.surfaceContainerHighest, child: Icon( Icons.playlist_play, - size: 64, + size: fallbackIconSize, color: colorScheme.onSurfaceVariant, ), ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 86dca61c..40f46b89 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { 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 { 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 { ), // 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 { } 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), diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index baa6035b..64c88eb8 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -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, + ), ], ), ), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index ad8c8029..17a174a6 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -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: () { diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart new file mode 100644 index 00000000..b39bdf9b --- /dev/null +++ b/lib/screens/settings/cache_management_page.dart @@ -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 createState() => + _CacheManagementPageState(); +} + +class _CacheManagementPageState extends ConsumerState { + // 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 _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 _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 _clearAppCache() async { + final cacheDir = await getApplicationCacheDirectory(); + await _clearDirectoryContents(cacheDir.path); + } + + Future _clearTempCache() async { + final tempDir = await getTemporaryDirectory(); + await _clearDirectoryContents(tempDir.path); + } + + Future _clearCoverCache() async { + await CoverCacheManager.clearCache(); + } + + Future _clearLibraryCoverCache() async { + final appSupportDir = await getApplicationSupportDirectory(); + final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); + await _clearDirectoryContents(libraryCoverDir.path); + } + + Future _clearExploreCache() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_exploreCacheKey); + await prefs.remove(_exploreCacheTsKey); + } + + Future _clearTrackCache() async { + await PlatformBridge.clearTrackCache(); + } + + Future _clearAllCaches() async { + final currentOverview = _overview; + await _clearAppCache(); + if (currentOverview != null && !currentOverview.tempIsSameAsAppCache) { + await _clearTempCache(); + } + await _clearCoverCache(); + await _clearLibraryCoverCache(); + await _clearExploreCache(); + await _clearTrackCache(); + } + + Future _confirmClear(String target) async { + final confirm = await showDialog( + 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 _confirmClearAll() async { + final confirm = await showDialog( + 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 _runAction( + String actionKey, + Future 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 _confirmAndRunAction({ + required String actionKey, + required String targetLabel, + required Future Function() action, + }) async { + final confirmed = await _confirmClear(targetLabel); + if (!confirmed) return; + + if (!mounted) return; + await _runAction( + actionKey, + action, + successMessage: context.l10n.cacheClearSuccess(targetLabel), + ); + } + + Future _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, + }); +} diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index ef662c02..6a145d52 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -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', diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 335e2f51..6ce36ed6 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -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 { 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 { 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, ), ], ), diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 1f27ef88..090978e0 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -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 { ); final colorScheme = Theme.of(context).colorScheme; - final topPadding = MediaQuery.of(context).padding.top; + final topPadding = normalizedHeaderTopPadding(context); final hasError = extension.status == 'error'; return PopScope( diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index d979d5f9..5c13458b 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -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 { 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 diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index c22f03d4..696baee9 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -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 { // -> /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 { return; } - await ref.read(localLibraryProvider.notifier).startScan( - libraryPath, - forceFullScan: forceFullScan, - ); + await ref + .read(localLibraryProvider.notifier) + .startScan(libraryPath, forceFullScan: forceFullScan); } Future _cancelScan() async { @@ -216,7 +217,7 @@ class _LibrarySettingsPageState extends ConsumerState { 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 { 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 { 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 { 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 { 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, + ), ), ), ], diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index f638d7b5..310d0805 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -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 { @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( diff --git a/lib/screens/settings/metadata_provider_priority_page.dart b/lib/screens/settings/metadata_provider_priority_page.dart index 62e7c3a9..51a78e4e 100644 --- a/lib/screens/settings/metadata_provider_priority_page.dart +++ b/lib/screens/settings/metadata_provider_priority_page.dart @@ -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 { @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, diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index b7d21c55..df5bc8d0 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -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, diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 88f8f141..cba73a38 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -248,7 +248,9 @@ class _SetupScreenState extends ConsumerState { 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 { } 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, ), ), ], diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 2114777f..d6689886 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -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 { 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( diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index 8447ad8c..b93ed5b7 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -16,6 +16,26 @@ class _TutorialScreenState extends ConsumerState { 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 { 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 { 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 { 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 { ); }), ), - 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), ], ), ); diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 3faaa991..cfe7d70b 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -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 _execute(String command) async { try { final session = await FFmpegKit.execute(command); @@ -269,8 +278,7 @@ class FFmpegService { Map? 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? 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? 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" '); diff --git a/lib/utils/app_bar_layout.dart b/lib/utils/app_bar_layout.dart new file mode 100644 index 00000000..d0a99dcd --- /dev/null +++ b/lib/utils/app_bar_layout.dart @@ -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; +} diff --git a/lib/widgets/collapsing_header.dart b/lib/widgets/collapsing_header.dart index 44681c96..276f8f43 100644 --- a/lib/widgets/collapsing_header.dart +++ b/lib/widgets/collapsing_header.dart @@ -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: [