mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
feat: responsive layout tuning, cache management page, and improved recent access UX
- Add responsive scaling across album, artist, playlist, downloaded album, local album, queue, setup, and tutorial screens to prevent overflow on smaller devices - Add new Storage & Cache management page (Settings > Storage & Cache) with per-category clear and cleanup actions - Extract normalizedHeaderTopPadding utility for consistent app bar padding - Improve home search Recent Access behavior: show when focused with empty input, hide stale results during active recent mode - Add excluded-downloaded-count tracking to local library scan stats - Add recentEmpty and recentShowAllDownloads l10n keys (EN + ID) - Add full cache management l10n keys (EN + ID) - Fix about_page indentation and formatting consistency - Fix appearance_settings_page formatting - Fix downloaded_album_screen and local_album_screen formatting and responsive sizing
This commit is contained in:
parent
5fdf1df5df
commit
23f5aa11b0
52 changed files with 5184 additions and 1565 deletions
30
CHANGELOG.md
30
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
|||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
|
||||
folderOrganization: json['folderOrganization'] as String? ?? 'none',
|
||||
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
|
||||
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
|
||||
historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
|
||||
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
|
||||
|
|
@ -68,6 +69,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
|||
'updateChannel': instance.updateChannel,
|
||||
'hasSearchedBefore': instance.hasSearchedBefore,
|
||||
'folderOrganization': instance.folderOrganization,
|
||||
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
|
||||
'historyViewMode': instance.historyViewMode,
|
||||
'historyFilterMode': instance.historyFilterMode,
|
||||
'askQualityBeforeDownload': instance.askQualityBeforeDownload,
|
||||
|
|
|
|||
|
|
@ -1027,14 +1027,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
String folderOrganization, {
|
||||
bool separateSingles = false,
|
||||
String albumFolderStructure = 'artist_album',
|
||||
bool useAlbumArtistForFolders = true,
|
||||
}) async {
|
||||
String baseDir = state.outputDir;
|
||||
final albumArtist =
|
||||
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
final folderArtist = useAlbumArtistForFolders
|
||||
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
||||
: track.artistName;
|
||||
|
||||
if (separateSingles) {
|
||||
final isSingle = track.isSingle;
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final artistName = _sanitizeFolderName(folderArtist);
|
||||
|
||||
if (albumFolderStructure == 'artist_album_singles') {
|
||||
if (isSingle) {
|
||||
|
|
@ -1092,7 +1094,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
String subPath = '';
|
||||
switch (folderOrganization) {
|
||||
case 'artist':
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final artistName = _sanitizeFolderName(folderArtist);
|
||||
subPath = artistName;
|
||||
break;
|
||||
case 'album':
|
||||
|
|
@ -1100,7 +1102,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
subPath = albumName;
|
||||
break;
|
||||
case 'artist_album':
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final artistName = _sanitizeFolderName(folderArtist);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
subPath = '$artistName${Platform.pathSeparator}$albumName';
|
||||
break;
|
||||
|
|
@ -1144,13 +1146,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
String folderOrganization, {
|
||||
bool separateSingles = false,
|
||||
String albumFolderStructure = 'artist_album',
|
||||
bool useAlbumArtistForFolders = true,
|
||||
}) async {
|
||||
final albumArtist =
|
||||
_normalizeOptionalString(track.albumArtist) ?? track.artistName;
|
||||
final folderArtist = useAlbumArtistForFolders
|
||||
? _normalizeOptionalString(track.albumArtist) ?? track.artistName
|
||||
: track.artistName;
|
||||
|
||||
if (separateSingles) {
|
||||
final isSingle = track.isSingle;
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final artistName = _sanitizeFolderName(folderArtist);
|
||||
|
||||
if (albumFolderStructure == 'artist_album_singles') {
|
||||
if (isSingle) {
|
||||
|
|
@ -1186,11 +1190,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
|
||||
switch (folderOrganization) {
|
||||
case 'artist':
|
||||
return _sanitizeFolderName(albumArtist);
|
||||
return _sanitizeFolderName(folderArtist);
|
||||
case 'album':
|
||||
return _sanitizeFolderName(track.albumName);
|
||||
case 'artist_album':
|
||||
final artistName = _sanitizeFolderName(albumArtist);
|
||||
final artistName = _sanitizeFolderName(folderArtist);
|
||||
final albumName = _sanitizeFolderName(track.albumName);
|
||||
return '$artistName/$albumName';
|
||||
default:
|
||||
|
|
@ -1214,8 +1218,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
case '.opus':
|
||||
return 'audio/ogg';
|
||||
case '.flac':
|
||||
default:
|
||||
return 'audio/flac';
|
||||
case '.lrc':
|
||||
return 'application/octet-stream';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2163,7 +2170,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
treeUri: treeUri,
|
||||
relativeDir: relativeDir,
|
||||
fileName: lrcName,
|
||||
mimeType: 'text/plain',
|
||||
mimeType: _mimeTypeForExt('.lrc'),
|
||||
srcPath: tempPath,
|
||||
);
|
||||
if (uri != null) {
|
||||
|
|
@ -2530,6 +2537,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
)
|
||||
: '';
|
||||
String? appOutputDir;
|
||||
|
|
@ -2540,6 +2548,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
);
|
||||
var effectiveOutputDir = initialOutputDir;
|
||||
var effectiveSafMode = isSafMode;
|
||||
|
|
@ -2736,6 +2745,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
settings.folderOrganization,
|
||||
separateSingles: settings.separateSingles,
|
||||
albumFolderStructure: settings.albumFolderStructure,
|
||||
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
||||
);
|
||||
final fallbackResult = await runDownload(
|
||||
useSaf: false,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/logger.dart';
|
|||
final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||
|
||||
class LocalLibraryState {
|
||||
final List<LocalLibraryItem> items;
|
||||
|
|
@ -22,6 +23,7 @@ class LocalLibraryState {
|
|||
final int scanErrorCount;
|
||||
final bool scanWasCancelled;
|
||||
final DateTime? lastScannedAt;
|
||||
final int excludedDownloadedCount;
|
||||
final Set<String> _isrcSet;
|
||||
final Set<String> _trackKeySet;
|
||||
final Map<String, LocalLibraryItem> _byIsrc;
|
||||
|
|
@ -36,6 +38,7 @@ class LocalLibraryState {
|
|||
this.scanErrorCount = 0,
|
||||
this.scanWasCancelled = false,
|
||||
this.lastScannedAt,
|
||||
this.excludedDownloadedCount = 0,
|
||||
}) : _isrcSet = items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => item.isrc!)
|
||||
|
|
@ -81,6 +84,7 @@ class LocalLibraryState {
|
|||
int? scanErrorCount,
|
||||
bool? scanWasCancelled,
|
||||
DateTime? lastScannedAt,
|
||||
int? excludedDownloadedCount,
|
||||
}) {
|
||||
return LocalLibraryState(
|
||||
items: items ?? this.items,
|
||||
|
|
@ -92,6 +96,8 @@ class LocalLibraryState {
|
|||
scanErrorCount: scanErrorCount ?? this.scanErrorCount,
|
||||
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
|
||||
lastScannedAt: lastScannedAt ?? this.lastScannedAt,
|
||||
excludedDownloadedCount:
|
||||
excludedDownloadedCount ?? this.excludedDownloadedCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -126,19 +132,27 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
|
||||
DateTime? lastScannedAt;
|
||||
var excludedDownloadedCount = 0;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||
}
|
||||
excludedDownloadedCount =
|
||||
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
|
||||
} catch (e) {
|
||||
_log.w('Failed to load lastScannedAt: $e');
|
||||
}
|
||||
|
||||
state = state.copyWith(items: items, lastScannedAt: lastScannedAt);
|
||||
state = state.copyWith(
|
||||
items: items,
|
||||
lastScannedAt: lastScannedAt,
|
||||
excludedDownloadedCount: excludedDownloadedCount,
|
||||
);
|
||||
_log.i(
|
||||
'Loaded ${items.length} items from library database, lastScannedAt: $lastScannedAt',
|
||||
'Loaded ${items.length} items from library database, lastScannedAt: '
|
||||
'$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount',
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_log.e('Failed to load library from database: $e', e, stack);
|
||||
|
|
@ -174,8 +188,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||
);
|
||||
|
||||
try {
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
final coverCacheDir = '${cacheDir.path}/library_covers';
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final coverCacheDir = '${appSupportDir.path}/library_covers';
|
||||
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
|
||||
_log.i('Cover cache directory set to: $coverCacheDir');
|
||||
} catch (e) {
|
||||
|
|
@ -226,6 +240,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
|
|
@ -237,9 +252,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
);
|
||||
|
||||
_log.i('Full scan complete: ${items.length} tracks found');
|
||||
_log.i(
|
||||
'Full scan complete: ${items.length} tracks found, '
|
||||
'$skippedDownloads already in downloads',
|
||||
);
|
||||
} else {
|
||||
// Incremental scan path - only scans new/modified files
|
||||
final existingFiles = await _db.getFileModTimes();
|
||||
|
|
@ -344,6 +363,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_lastScannedAtKey, now.toIso8601String());
|
||||
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
|
||||
_log.d('Saved lastScannedAt: $now');
|
||||
} catch (e) {
|
||||
_log.w('Failed to save lastScannedAt: $e');
|
||||
|
|
@ -355,11 +375,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||
scanProgress: 100,
|
||||
lastScannedAt: now,
|
||||
scanWasCancelled: false,
|
||||
excludedDownloadedCount: skippedDownloads,
|
||||
);
|
||||
|
||||
_log.i(
|
||||
'Incremental scan complete: ${items.length} total tracks '
|
||||
'(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)',
|
||||
'(${scannedList.length} new/updated, $skippedCount unchanged, '
|
||||
'${deletedPaths.length} removed, $skippedDownloads already in downloads)',
|
||||
);
|
||||
}
|
||||
} catch (e, stack) {
|
||||
|
|
@ -427,6 +449,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
|||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_lastScannedAtKey);
|
||||
await prefs.remove(_excludedDownloadedCountKey);
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear lastScannedAt: $e');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,6 +226,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
|||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUseAlbumArtistForFolders(bool enabled) {
|
||||
state = state.copyWith(useAlbumArtistForFolders: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setHistoryViewMode(String mode) {
|
||||
state = state.copyWith(historyViewMode: mode);
|
||||
_saveSettings();
|
||||
|
|
|
|||
|
|
@ -233,11 +233,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5;
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
final screenWidth = mediaSize.width;
|
||||
final shortestSide = mediaSize.shortestSide;
|
||||
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
|
|
@ -259,7 +265,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio =
|
||||
(constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
|
|
@ -292,7 +299,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
height: bottomGradientHeight,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
|
|
@ -311,7 +318,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
padding: EdgeInsets.only(top: coverTopPadding),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
|
|
@ -338,7 +345,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
size: 64,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -113,6 +113,37 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
List<ArtistAlbum> _singlesBucket = const [];
|
||||
List<ArtistAlbum> _compilationsBucket = const [];
|
||||
|
||||
double _responsiveScale({
|
||||
double min = 0.82,
|
||||
double max = 1.08,
|
||||
double baseShortestSide = 390,
|
||||
}) {
|
||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
final scale = shortestSide / baseShortestSide;
|
||||
if (scale < min) return min;
|
||||
if (scale > max) return max;
|
||||
return scale;
|
||||
}
|
||||
|
||||
double _effectiveTextScale() {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
if (textScale < 1.0) return 1.0;
|
||||
if (textScale > 1.4) return 1.4;
|
||||
return textScale;
|
||||
}
|
||||
|
||||
double _artistAlbumTileSize() {
|
||||
final scale = _responsiveScale(min: 0.82, max: 1.05);
|
||||
final textScale = _effectiveTextScale();
|
||||
return 140 * scale * (1 + (textScale - 1) * 0.12);
|
||||
}
|
||||
|
||||
double _artistAlbumSectionHeight() {
|
||||
final tileSize = _artistAlbumTileSize();
|
||||
final textScale = _effectiveTextScale();
|
||||
return tileSize + 64 + ((textScale - 1) * 14);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -1412,6 +1443,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
List<ArtistAlbum> albums,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final sectionHeight = _artistAlbumSectionHeight();
|
||||
final tileSize = _artistAlbumTileSize();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -1425,7 +1459,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 220,
|
||||
height: sectionHeight,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
|
|
@ -1434,7 +1468,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
final album = albums[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(album.id),
|
||||
child: _buildAlbumCard(album, colorScheme),
|
||||
child: _buildAlbumCard(album, colorScheme, tileSize: tileSize, sectionHeight: sectionHeight),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -1443,7 +1477,12 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumCard(ArtistAlbum album, ColorScheme colorScheme) {
|
||||
Widget _buildAlbumCard(
|
||||
ArtistAlbum album,
|
||||
ColorScheme colorScheme, {
|
||||
required double tileSize,
|
||||
required double sectionHeight,
|
||||
}) {
|
||||
final isSelected = _selectedAlbumIds.contains(album.id);
|
||||
|
||||
return GestureDetector(
|
||||
|
|
@ -1460,7 +1499,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 140,
|
||||
width: tileSize,
|
||||
height: sectionHeight,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -1472,19 +1512,19 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
child: album.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: album.coverUrl!,
|
||||
width: 140,
|
||||
height: 140,
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 280,
|
||||
memCacheWidth: (tileSize * 2).round(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (context, url) => Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
|
|
@ -1494,8 +1534,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
),
|
||||
)
|
||||
: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
width: tileSize,
|
||||
height: tileSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
|
|
@ -1553,26 +1593,36 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
album.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
album.totalTracks > 0
|
||||
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
|
||||
: album.releaseDate.length >= 4
|
||||
? album.releaseDate.substring(0, 4)
|
||||
: album.releaseDate,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
album.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
album.totalTracks > 0
|
||||
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}'
|
||||
: album.releaseDate.length >= 4
|
||||
? album.releaseDate.substring(0, 4)
|
||||
: album.releaseDate,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
|
|||
});
|
||||
|
||||
@override
|
||||
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState();
|
||||
ConsumerState<DownloadedAlbumScreen> createState() =>
|
||||
_DownloadedAlbumScreenState();
|
||||
}
|
||||
|
||||
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
||||
|
|
@ -53,27 +54,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
}
|
||||
|
||||
/// Get tracks for this album from history provider (reactive)
|
||||
List<DownloadHistoryItem> _getAlbumTracks(List<DownloadHistoryItem> allItems) {
|
||||
List<DownloadHistoryItem> _getAlbumTracks(
|
||||
List<DownloadHistoryItem> allItems,
|
||||
) {
|
||||
return allItems.where((item) {
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
// Use albumArtist if available and not empty, otherwise artistName
|
||||
final itemArtist =
|
||||
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
|
||||
? item.albumArtist!
|
||||
: item.artistName;
|
||||
// Use lowercase for case-insensitive matching
|
||||
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||
final itemKey =
|
||||
'${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
|
||||
final albumKey =
|
||||
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
|
||||
return itemKey == albumKey;
|
||||
}).toList()
|
||||
..sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
}).toList()..sort((a, b) {
|
||||
// Sort by disc number first, then by track number
|
||||
final aDisc = a.discNumber ?? 1;
|
||||
final bDisc = b.discNumber ?? 1;
|
||||
if (aDisc != bDisc) return aDisc.compareTo(bDisc);
|
||||
final aNum = a.trackNumber ?? 999;
|
||||
final bNum = b.trackNumber ?? 999;
|
||||
if (aNum != bNum) return aNum.compareTo(bNum);
|
||||
return a.trackName.compareTo(b.trackName);
|
||||
});
|
||||
}
|
||||
|
||||
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
|
||||
|
|
@ -147,7 +152,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
if (confirmed == true && mounted) {
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
final idsToDelete = _selectedIds.toList();
|
||||
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in idsToDelete) {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
|
|
@ -159,12 +164,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -176,7 +183,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -184,12 +193,17 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
|
||||
void _navigateToMetadataScreen(DownloadHistoryItem item) {
|
||||
_precacheCover(item.coverUrl);
|
||||
Navigator.push(context, PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child),
|
||||
));
|
||||
Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 250),
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
TrackMetadataScreen(item: item),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) =>
|
||||
FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _precacheCover(String? url) {
|
||||
|
|
@ -207,22 +221,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
|
||||
final allHistoryItems = ref.watch(downloadHistoryProvider.select((s) => s.items));
|
||||
|
||||
final allHistoryItems = ref.watch(
|
||||
downloadHistoryProvider.select((s) => s.items),
|
||||
);
|
||||
final tracks = _getAlbumTracks(allHistoryItems);
|
||||
|
||||
|
||||
// Show empty state if no tracks found
|
||||
if (tracks.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.albumName),
|
||||
),
|
||||
body: Center(
|
||||
child: Text('No tracks found for this album'),
|
||||
),
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: Center(child: Text('No tracks found for this album')),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
final validIds = tracks.map((t) => t.id).toSet();
|
||||
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||
|
|
@ -248,17 +260,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
_buildInfoCard(context, colorScheme, tracks),
|
||||
_buildTrackListHeader(context, colorScheme, tracks),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
||||
child: _buildSelectionBottomBar(
|
||||
context,
|
||||
colorScheme,
|
||||
tracks,
|
||||
bottomPadding,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -267,14 +286,21 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
final screenWidth = mediaSize.width;
|
||||
final shortestSide = mediaSize.shortestSide;
|
||||
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface, // Use theme color for collapsed state
|
||||
backgroundColor:
|
||||
colorScheme.surface, // Use theme color for collapsed state
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
|
|
@ -292,9 +318,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
final collapseRatio =
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
|
|
@ -306,25 +334,35 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
placeholder: (_, _) => Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) => Container(color: colorScheme.surface),
|
||||
placeholder: (_, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
errorWidget: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
|
||||
child: Container(
|
||||
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0, height: 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: bottomGradientHeight,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
|
||||
colors: [
|
||||
colorScheme.surface.withValues(alpha: 0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -335,7 +373,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
padding: EdgeInsets.only(top: coverTopPadding),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
|
|
@ -352,15 +390,19 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: (coverSize * 2).toInt(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -369,14 +411,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
stretchModes: const [
|
||||
StretchMode.zoomBackground,
|
||||
StretchMode.blurBackground,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
@ -384,14 +432,20 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
|
|
@ -399,43 +453,70 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer),
|
||||
Icon(
|
||||
Icons.download_done,
|
||||
size: 14,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(context.l10n.downloadedAlbumDownloadedCount(tracks.length), style: TextStyle(color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(
|
||||
context.l10n.downloadedAlbumDownloadedCount(
|
||||
tracks.length,
|
||||
),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (_getCommonQuality(tracks) != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
? colorScheme.tertiaryContainer
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
? colorScheme.tertiaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getCommonQuality(tracks)!,
|
||||
style: TextStyle(
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
? colorScheme.onTertiaryContainer
|
||||
color: _getCommonQuality(tracks)!.startsWith('24')
|
||||
? colorScheme.onTertiaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
|
|
@ -462,7 +543,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
return firstQuality;
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
Widget _buildTrackListHeader(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
|
|
@ -470,14 +555,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||
Text(
|
||||
context.l10n.downloadedAlbumTracksHeader,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
TextButton.icon(
|
||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||
onPressed: tracks.isNotEmpty
|
||||
? () => _enterSelectionMode(tracks.first.id)
|
||||
: null,
|
||||
icon: const Icon(Icons.checklist, size: 18),
|
||||
label: Text(context.l10n.actionSelect),
|
||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -485,21 +580,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) {
|
||||
Widget _buildTrackList(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
) {
|
||||
final discMap = _groupTracksByDisc(tracks);
|
||||
|
||||
if (discMap.length <= 1) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
);
|
||||
},
|
||||
childCount: tracks.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final track = tracks[index];
|
||||
return KeyedSubtree(
|
||||
key: ValueKey(track.id),
|
||||
child: _buildTrackItem(context, colorScheme, track),
|
||||
);
|
||||
}, childCount: tracks.length),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -524,12 +620,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
}
|
||||
}
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildListDelegate(children),
|
||||
);
|
||||
return SliverList(delegate: SliverChildListDelegate(children));
|
||||
}
|
||||
|
||||
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) {
|
||||
Widget _buildDiscSeparator(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
int discNumber,
|
||||
) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
|
|
@ -543,7 +641,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
Icon(
|
||||
Icons.album,
|
||||
size: 16,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
||||
|
|
@ -567,21 +669,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, DownloadHistoryItem track) {
|
||||
Widget _buildTrackItem(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
DownloadHistoryItem track,
|
||||
) {
|
||||
final isSelected = _selectedIds.contains(track.id);
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onTap: _isSelectionMode
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(track.id)
|
||||
: () => _navigateToMetadataScreen(track),
|
||||
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
||||
onLongPress: _isSelectionMode
|
||||
? null
|
||||
: () => _enterSelectionMode(track.id),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -590,12 +702,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
|
@ -617,7 +740,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
track.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
track.artistName,
|
||||
|
|
@ -625,22 +750,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
trailing: _isSelectionMode ? null : IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
trailing: _isSelectionMode
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks, double bottomPadding) {
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<DownloadHistoryItem> tracks,
|
||||
double bottomPadding,
|
||||
) {
|
||||
final selectedCount = _selectedIds.length;
|
||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
|
|
@ -684,12 +818,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
context.l10n.downloadedAlbumSelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
allSelected
|
||||
? context.l10n.downloadedAlbumAllSelected
|
||||
: context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -702,9 +842,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
_selectAll(tracks);
|
||||
}
|
||||
},
|
||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||
icon: Icon(
|
||||
allSelected ? Icons.deselect : Icons.select_all,
|
||||
size: 20,
|
||||
),
|
||||
label: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -712,18 +861,26 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
|
|||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
||||
onPressed: selectedCount > 0
|
||||
? () => _deleteSelected(tracks)
|
||||
: null,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
selectedCount > 0
|
||||
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
|
||||
: context.l10n.downloadedAlbumSelectToDelete,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
||||
backgroundColor: selectedCount > 0
|
||||
? colorScheme.error
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0
|
||||
? colorScheme.onError
|
||||
: colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import 'package:spotiflac_android/screens/album_screen.dart';
|
|||
import 'package:spotiflac_android/screens/artist_screen.dart';
|
||||
import 'package:spotiflac_android/services/csv_import_service.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/screens/playlist_screen.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
|
|
@ -74,6 +75,50 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
Set<String>? _recentAccessHiddenIdsCache;
|
||||
_RecentAccessView? _recentAccessViewCache;
|
||||
|
||||
double _responsiveScale({
|
||||
required BuildContext context,
|
||||
double min = 0.82,
|
||||
double max = 1.08,
|
||||
double baseShortestSide = 390,
|
||||
}) {
|
||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
final scale = shortestSide / baseShortestSide;
|
||||
if (scale < min) return min;
|
||||
if (scale > max) return max;
|
||||
return scale;
|
||||
}
|
||||
|
||||
double _effectiveTextScale(BuildContext context) {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
if (textScale < 1.0) return 1.0;
|
||||
if (textScale > 1.4) return 1.4;
|
||||
return textScale;
|
||||
}
|
||||
|
||||
double _recentDownloadCoverSize(BuildContext context) {
|
||||
final scale = _responsiveScale(context: context, min: 0.82, max: 1.05);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return 100 * scale * (1 + (textScale - 1) * 0.15);
|
||||
}
|
||||
|
||||
double _recentDownloadsRowHeight(BuildContext context) {
|
||||
final coverSize = _recentDownloadCoverSize(context);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return coverSize + 28 + ((textScale - 1) * 8);
|
||||
}
|
||||
|
||||
double _exploreCardSize(BuildContext context) {
|
||||
final scale = _responsiveScale(context: context, min: 0.82, max: 1.08);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return 120 * scale * (1 + (textScale - 1) * 0.12);
|
||||
}
|
||||
|
||||
double _exploreSectionHeight(BuildContext context) {
|
||||
final cardSize = _exploreCardSize(context);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
return cardSize + 55 + ((textScale - 1) * 12);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
|
|
@ -153,6 +198,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
}
|
||||
|
||||
void _onSearchFocusChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
if (_searchFocusNode.hasFocus) {
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(true);
|
||||
}
|
||||
|
|
@ -324,6 +372,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
if (trackState.albumId != null &&
|
||||
trackState.albumName != null &&
|
||||
trackState.tracks.isNotEmpty) {
|
||||
final extensionId = trackState.searchExtensionId;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -332,6 +381,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
albumName: trackState.albumName!,
|
||||
coverUrl: trackState.coverUrl,
|
||||
tracks: trackState.tracks,
|
||||
extensionId: extensionId,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -348,7 +398,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
id: trackState.playlistName!,
|
||||
name: trackState.playlistName!,
|
||||
imageUrl: trackState.coverUrl,
|
||||
providerId: 'spotify',
|
||||
providerId: trackState.searchExtensionId ?? 'spotify',
|
||||
);
|
||||
|
||||
Navigator.push(
|
||||
|
|
@ -370,6 +420,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
if (trackState.artistId != null &&
|
||||
trackState.artistName != null &&
|
||||
trackState.artistAlbums != null) {
|
||||
final extensionId = trackState.searchExtensionId;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
@ -378,6 +429,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
artistName: trackState.artistName!,
|
||||
coverUrl: trackState.coverUrl,
|
||||
albums: trackState.artistAlbums!,
|
||||
extensionId: extensionId,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -586,7 +638,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
trackName: l10n.csvImportTracks(tracksToQueue.length),
|
||||
artistName: l10n.dialogImportPlaylistTitle,
|
||||
onSelect: (quality, service) {
|
||||
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(
|
||||
ref
|
||||
.read(downloadQueueProvider.notifier)
|
||||
.addMultipleToQueue(
|
||||
tracksToQueue,
|
||||
service,
|
||||
qualityOverride: quality,
|
||||
|
|
@ -662,13 +716,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
(searchArtists != null && searchArtists.isNotEmpty) ||
|
||||
(searchAlbums != null && searchAlbums.isNotEmpty) ||
|
||||
(searchPlaylists != null && searchPlaylists.isNotEmpty);
|
||||
final searchText = _urlController.text.trim();
|
||||
final hasSearchInput = searchText.isNotEmpty;
|
||||
final isSearchFocused = _searchFocusNode.hasFocus;
|
||||
final hasShortSearchInput =
|
||||
hasSearchInput && searchText.length < _minLiveSearchChars;
|
||||
final isShowingRecentAccess = ref.watch(
|
||||
trackProvider.select((s) => s.isShowingRecentAccess),
|
||||
);
|
||||
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final screenHeight = mediaQuery.size.height;
|
||||
final topPadding = mediaQuery.padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final historyItems = ref.watch(
|
||||
downloadHistoryProvider.select((s) => s.items),
|
||||
);
|
||||
|
|
@ -679,13 +737,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
recentAccessProvider.select((s) => s.hiddenDownloadIds),
|
||||
);
|
||||
|
||||
final hasRecentItems =
|
||||
recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
|
||||
final recentModeRequested = isShowingRecentAccess || isSearchFocused;
|
||||
final showRecentAccess =
|
||||
isShowingRecentAccess &&
|
||||
hasRecentItems &&
|
||||
!hasActualResults &&
|
||||
recentModeRequested &&
|
||||
(!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
|
||||
!isLoading;
|
||||
final hasResults =
|
||||
hasSearchInput || hasActualResults || isLoading || showRecentAccess;
|
||||
final recentAccessView = showRecentAccess
|
||||
? _getRecentAccessView(
|
||||
recentAccessItems,
|
||||
|
|
@ -752,7 +810,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
];
|
||||
}
|
||||
|
||||
if (hasActualResults && isShowingRecentAccess) {
|
||||
if (hasActualResults &&
|
||||
isShowingRecentAccess &&
|
||||
hasSearchInput &&
|
||||
!isSearchFocused) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
ref.read(trackProvider.notifier).setShowingRecentAccess(false);
|
||||
|
|
@ -871,7 +932,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
),
|
||||
|
||||
// Search filter bar (only shown when has search results)
|
||||
if (searchFilters.isNotEmpty && hasActualResults)
|
||||
if (searchFilters.isNotEmpty && hasActualResults && !showRecentAccess)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildSearchFilterBar(
|
||||
searchFilters,
|
||||
|
|
@ -949,7 +1010,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
isLoading: isLoading,
|
||||
error: error,
|
||||
colorScheme: colorScheme,
|
||||
hasResults: hasResults,
|
||||
hasResults: hasActualResults || isLoading,
|
||||
searchExtensionId: searchExtensionId,
|
||||
showLocalLibraryIndicator: showLocalLibraryIndicator,
|
||||
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
|
||||
|
|
@ -966,6 +1027,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
ColorScheme colorScheme,
|
||||
) {
|
||||
final itemCount = items.length < 10 ? items.length : 10;
|
||||
final coverSize = _recentDownloadCoverSize(context);
|
||||
final rowHeight = _recentDownloadsRowHeight(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -980,7 +1043,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 130,
|
||||
height: rowHeight,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: itemCount,
|
||||
|
|
@ -991,7 +1054,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
child: GestureDetector(
|
||||
onTap: () => _navigateToMetadataScreen(item),
|
||||
child: Container(
|
||||
width: 100,
|
||||
width: coverSize,
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
|
|
@ -1000,16 +1063,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
child: item.coverUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 100,
|
||||
height: 100,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 200,
|
||||
memCacheHeight: 200,
|
||||
memCacheWidth: (coverSize * 2).round(),
|
||||
memCacheHeight: (coverSize * 2).round(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
)
|
||||
: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.music_note,
|
||||
|
|
@ -1177,6 +1240,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
}
|
||||
|
||||
Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) {
|
||||
final sectionHeight = _exploreSectionHeight(context);
|
||||
if (section.isYTMusicQuickPicks) {
|
||||
return _buildYTMusicQuickPicksSection(section, colorScheme);
|
||||
}
|
||||
|
|
@ -1194,7 +1258,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 175,
|
||||
height: sectionHeight,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
|
|
@ -1229,11 +1293,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
|
||||
Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) {
|
||||
final isArtist = item.type == 'artist';
|
||||
final cardSize = _exploreCardSize(context);
|
||||
final iconSize = cardSize * 0.3;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToExploreItem(item),
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
width: cardSize,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Column(
|
||||
|
|
@ -1242,35 +1308,37 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(isArtist ? 60 : 8),
|
||||
borderRadius: BorderRadius.circular(
|
||||
isArtist ? cardSize / 2 : 8,
|
||||
),
|
||||
child: item.coverUrl != null && item.coverUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: item.coverUrl!,
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 240,
|
||||
memCacheHeight: 240,
|
||||
memCacheWidth: (cardSize * 2).round(),
|
||||
memCacheHeight: (cardSize * 2).round(),
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_getIconForType(item.type),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 36,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
_getIconForType(item.type),
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 36,
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -1571,14 +1639,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (uniqueItems.isEmpty && hasHiddenDownloads)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
if (uniqueItems.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.visibility_off,
|
||||
hasHiddenDownloads ? Icons.visibility_off : Icons.history,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.5,
|
||||
|
|
@ -1586,21 +1656,24 @@ class _HomeTabState extends ConsumerState<HomeTab>
|
|||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'No recent items',
|
||||
context.l10n.recentEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.clearHiddenDownloads();
|
||||
},
|
||||
icon: const Icon(Icons.visibility, size: 18),
|
||||
label: const Text('Show All Downloads'),
|
||||
),
|
||||
if (hasHiddenDownloads) ...[
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ref
|
||||
.read(recentAccessProvider.notifier)
|
||||
.clearHiddenDownloads();
|
||||
},
|
||||
icon: const Icon(Icons.visibility, size: 18),
|
||||
label: Text(context.l10n.recentShowAllDownloads),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -89,7 +89,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
|
||||
}
|
||||
|
||||
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(List<LocalLibraryItem> tracks) {
|
||||
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
|
||||
List<LocalLibraryItem> tracks,
|
||||
) {
|
||||
final discMap = <int, List<LocalLibraryItem>>{};
|
||||
for (final track in tracks) {
|
||||
final discNumber = track.discNumber ?? 1;
|
||||
|
|
@ -158,7 +160,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
if (confirmed == true && mounted) {
|
||||
final libraryNotifier = ref.read(localLibraryProvider.notifier);
|
||||
final idsToDelete = _selectedIds.toList();
|
||||
|
||||
|
||||
int deletedCount = 0;
|
||||
for (final id in idsToDelete) {
|
||||
final item = currentTracks.where((e) => e.id == id).firstOrNull;
|
||||
|
|
@ -170,14 +172,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
deletedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_exitSelectionMode();
|
||||
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deletedCount))),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarDeletedTracks(deletedCount)),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
// Go back if all tracks were deleted
|
||||
if (deletedCount == currentTracks.length) {
|
||||
Navigator.pop(context);
|
||||
|
|
@ -192,7 +196,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -203,19 +209,15 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
final tracks = _sortedTracksCache;
|
||||
|
||||
|
||||
// Show empty state if no tracks found
|
||||
if (tracks.isEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.albumName),
|
||||
),
|
||||
body: const Center(
|
||||
child: Text('No tracks found for this album'),
|
||||
),
|
||||
appBar: AppBar(title: Text(widget.albumName)),
|
||||
body: const Center(child: Text('No tracks found for this album')),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
final validIds = tracks.map((t) => t.id).toSet();
|
||||
_selectedIds.removeWhere((id) => !validIds.contains(id));
|
||||
if (_selectedIds.isEmpty && _isSelectionMode) {
|
||||
|
|
@ -241,17 +243,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
_buildInfoCard(context, colorScheme, tracks),
|
||||
_buildTrackListHeader(context, colorScheme, tracks),
|
||||
_buildTrackList(context, colorScheme, tracks),
|
||||
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 120 : 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding),
|
||||
child: _buildSelectionBottomBar(context, colorScheme, tracks, bottomPadding),
|
||||
child: _buildSelectionBottomBar(
|
||||
context,
|
||||
colorScheme,
|
||||
tracks,
|
||||
bottomPadding,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -260,11 +269,17 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5;
|
||||
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
final screenWidth = mediaSize.width;
|
||||
final shortestSide = mediaSize.shortestSide;
|
||||
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
|
|
@ -285,9 +300,11 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
),
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
final collapseRatio =
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.none,
|
||||
background: Stack(
|
||||
|
|
@ -298,24 +315,33 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
Image.file(
|
||||
File(widget.coverPath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(color: colorScheme.surface),
|
||||
errorBuilder: (_, _, _) =>
|
||||
Container(color: colorScheme.surface),
|
||||
)
|
||||
else
|
||||
Container(color: colorScheme.surface),
|
||||
ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
|
||||
child: Container(color: colorScheme.surface.withValues(alpha: 0.4)),
|
||||
child: Container(
|
||||
color: colorScheme.surface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0, right: 0, bottom: 0, height: 80,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: bottomGradientHeight,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [colorScheme.surface.withValues(alpha: 0.0), colorScheme.surface],
|
||||
colors: [
|
||||
colorScheme.surface.withValues(alpha: 0.0),
|
||||
colorScheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -326,7 +352,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
padding: EdgeInsets.only(top: coverTopPadding),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
|
|
@ -349,13 +375,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
cacheWidth: (coverSize * 2).toInt(),
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
color:
|
||||
colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant),
|
||||
child: Icon(
|
||||
Icons.album,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -364,14 +399,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
stretchModes: const [StretchMode.zoomBackground, StretchMode.blurBackground],
|
||||
stretchModes: const [
|
||||
StretchMode.zoomBackground,
|
||||
StretchMode.blurBackground,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(color: colorScheme.surface.withValues(alpha: 0.8), shape: BoxShape.circle),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface.withValues(alpha: 0.8),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
|
|
@ -379,14 +420,20 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<LocalLibraryItem> tracks,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
|
|
@ -394,40 +441,79 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
children: [
|
||||
Text(
|
||||
widget.albumName,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold, color: colorScheme.onSurface),
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.artistName,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
// "Local" badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer),
|
||||
Icon(
|
||||
Icons.folder,
|
||||
size: 14,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text('Local', style: TextStyle(color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(
|
||||
'Local',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Track count
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant),
|
||||
Icon(
|
||||
Icons.music_note,
|
||||
size: 14,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text('${tracks.length} tracks', style: TextStyle(color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, fontSize: 12)),
|
||||
Text(
|
||||
'${tracks.length} tracks',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -435,18 +521,21 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
// Quality badge if all tracks have the same quality
|
||||
if (_getCommonQuality(tracks) != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _getCommonQuality(tracks)!.contains('24')
|
||||
? colorScheme.primaryContainer
|
||||
color: _getCommonQuality(tracks)!.contains('24')
|
||||
? colorScheme.primaryContainer
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
_getCommonQuality(tracks)!,
|
||||
style: TextStyle(
|
||||
color: _getCommonQuality(tracks)!.contains('24')
|
||||
? colorScheme.onPrimaryContainer
|
||||
color: _getCommonQuality(tracks)!.contains('24')
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
|
|
@ -467,17 +556,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
if (tracks.isEmpty) return null;
|
||||
final first = tracks.first;
|
||||
if (first.bitDepth == null || first.sampleRate == null) return null;
|
||||
|
||||
final firstQuality = '${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
|
||||
|
||||
final firstQuality =
|
||||
'${first.bitDepth}/${(first.sampleRate! / 1000).round()}kHz';
|
||||
for (final track in tracks) {
|
||||
if (track.bitDepth != first.bitDepth || track.sampleRate != first.sampleRate) {
|
||||
if (track.bitDepth != first.bitDepth ||
|
||||
track.sampleRate != first.sampleRate) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return firstQuality;
|
||||
}
|
||||
|
||||
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
||||
Widget _buildTrackListHeader(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<LocalLibraryItem> tracks,
|
||||
) {
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
|
|
@ -485,14 +580,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
children: [
|
||||
Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.downloadedAlbumTracksHeader, style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: colorScheme.onSurface)),
|
||||
Text(
|
||||
context.l10n.downloadedAlbumTracksHeader,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!_isSelectionMode)
|
||||
TextButton.icon(
|
||||
onPressed: tracks.isNotEmpty ? () => _enterSelectionMode(tracks.first.id) : null,
|
||||
onPressed: tracks.isNotEmpty
|
||||
? () => _enterSelectionMode(tracks.first.id)
|
||||
: null,
|
||||
icon: const Icon(Icons.checklist, size: 18),
|
||||
label: Text(context.l10n.actionSelect),
|
||||
style: TextButton.styleFrom(visualDensity: VisualDensity.compact),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -500,15 +605,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackList(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) {
|
||||
Widget _buildTrackList(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<LocalLibraryItem> tracks,
|
||||
) {
|
||||
final discGroups = _discGroupsCache;
|
||||
final hasMultipleDiscs = _hasMultipleDiscsCache;
|
||||
|
||||
|
||||
final slivers = <Widget>[];
|
||||
|
||||
|
||||
for (final discNumber in _sortedDiscNumbersCache) {
|
||||
final discTracks = discGroups[discNumber]!;
|
||||
|
||||
|
||||
if (hasMultipleDiscs) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
|
|
@ -517,7 +626,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
|
|
@ -525,14 +637,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
Icon(
|
||||
Icons.album,
|
||||
size: 16,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.downloadedAlbumDiscHeader(discNumber),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelLarge
|
||||
?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -550,35 +667,46 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
slivers.add(
|
||||
SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]),
|
||||
(context, index) =>
|
||||
_buildTrackItem(context, colorScheme, discTracks[index]),
|
||||
childCount: discTracks.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return SliverMainAxisGroup(slivers: slivers);
|
||||
}
|
||||
|
||||
Widget _buildTrackItem(BuildContext context, ColorScheme colorScheme, LocalLibraryItem track) {
|
||||
Widget _buildTrackItem(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
LocalLibraryItem track,
|
||||
) {
|
||||
final isSelected = _selectedIds.contains(track.id);
|
||||
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: isSelected ? colorScheme.primaryContainer.withValues(alpha: 0.3) : Colors.transparent,
|
||||
color: isSelected
|
||||
? colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: Colors.transparent,
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: ListTile(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onTap: _isSelectionMode
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onTap: _isSelectionMode
|
||||
? () => _toggleSelection(track.id)
|
||||
: () => _openFile(track.filePath),
|
||||
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id),
|
||||
onLongPress: _isSelectionMode
|
||||
? null
|
||||
: () => _enterSelectionMode(track.id),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
@ -587,12 +715,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? colorScheme.primary : Colors.transparent,
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.transparent,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: isSelected ? colorScheme.primary : colorScheme.outline, width: 2),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isSelected
|
||||
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16)
|
||||
child: isSelected
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
|
@ -614,7 +753,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
track.trackName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
|
|
@ -627,27 +768,45 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
),
|
||||
),
|
||||
if (track.format != null) ...[
|
||||
Text(' • ', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)),
|
||||
Text(
|
||||
' • ',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
track.format!.toUpperCase(),
|
||||
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12),
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _isSelectionMode ? null : IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
trailing: _isSelectionMode
|
||||
? null
|
||||
: IconButton(
|
||||
onPressed: () => _openFile(track.filePath),
|
||||
icon: Icon(Icons.play_arrow, color: colorScheme.primary),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks, double bottomPadding) {
|
||||
Widget _buildSelectionBottomBar(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
List<LocalLibraryItem> tracks,
|
||||
double bottomPadding,
|
||||
) {
|
||||
final selectedCount = _selectedIds.length;
|
||||
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
|
||||
|
||||
|
|
@ -694,12 +853,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.downloadedAlbumSelectedCount(selectedCount),
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
context.l10n.downloadedAlbumSelectedCount(
|
||||
selectedCount,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
allSelected
|
||||
? context.l10n.downloadedAlbumAllSelected
|
||||
: context.l10n.downloadedAlbumTapToSelect,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -712,9 +877,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
_selectAll(tracks);
|
||||
}
|
||||
},
|
||||
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20),
|
||||
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll),
|
||||
style: TextButton.styleFrom(foregroundColor: colorScheme.primary),
|
||||
icon: Icon(
|
||||
allSelected ? Icons.deselect : Icons.select_all,
|
||||
size: 20,
|
||||
),
|
||||
label: Text(
|
||||
allSelected
|
||||
? context.l10n.actionDeselect
|
||||
: context.l10n.actionSelectAll,
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -722,18 +896,26 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
|
|||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null,
|
||||
onPressed: selectedCount > 0
|
||||
? () => _deleteSelected(tracks)
|
||||
: null,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: Text(
|
||||
selectedCount > 0
|
||||
selectedCount > 0
|
||||
? context.l10n.downloadedAlbumDeleteCount(selectedCount)
|
||||
: context.l10n.downloadedAlbumSelectToDelete,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant,
|
||||
backgroundColor: selectedCount > 0
|
||||
? colorScheme.error
|
||||
: colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: selectedCount > 0
|
||||
? colorScheme.onError
|
||||
: colorScheme.onSurfaceVariant,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -390,7 +390,9 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||
body: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
|
||||
? const _NoSwipeRightPhysics()
|
||||
: const ClampingScrollPhysics(),
|
||||
children: tabs,
|
||||
),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
|
|
@ -413,6 +415,26 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Custom physics that blocks swiping to the right (next page) while
|
||||
/// still allowing vertical scrolling inside the page content.
|
||||
class _NoSwipeRightPhysics extends ScrollPhysics {
|
||||
const _NoSwipeRightPhysics({super.parent});
|
||||
|
||||
@override
|
||||
_NoSwipeRightPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return _NoSwipeRightPhysics(parent: buildParent(ancestor));
|
||||
}
|
||||
|
||||
@override
|
||||
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||
// In a horizontal PageView, a negative offset means the user is
|
||||
// dragging left (i.e. trying to go to the next page / right).
|
||||
// Block that direction only.
|
||||
if (offset < 0) return 0.0;
|
||||
return super.applyPhysicsToUserOffset(position, offset);
|
||||
}
|
||||
}
|
||||
|
||||
class BouncingIcon extends StatefulWidget {
|
||||
final Widget child;
|
||||
const BouncingIcon({super.key, required this.child});
|
||||
|
|
|
|||
|
|
@ -145,11 +145,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final coverSize = screenWidth * 0.5; // 50% of screen width
|
||||
final mediaSize = MediaQuery.of(context).size;
|
||||
final screenWidth = mediaSize.width;
|
||||
final shortestSide = mediaSize.shortestSide;
|
||||
final coverSize = (screenWidth * 0.5).clamp(140.0, 220.0);
|
||||
final expandedHeight = (shortestSide * 0.82).clamp(280.0, 340.0);
|
||||
final bottomGradientHeight = (shortestSide * 0.2).clamp(56.0, 80.0);
|
||||
final coverTopPadding = (shortestSide * 0.14).clamp(40.0, 60.0);
|
||||
final fallbackIconSize = (coverSize * 0.32).clamp(44.0, 64.0);
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 320,
|
||||
expandedHeight: expandedHeight,
|
||||
pinned: true,
|
||||
stretch: true,
|
||||
backgroundColor:
|
||||
|
|
@ -172,7 +178,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final collapseRatio =
|
||||
(constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight);
|
||||
(constraints.maxHeight - kToolbarHeight) /
|
||||
(expandedHeight - kToolbarHeight);
|
||||
final showContent = collapseRatio > 0.3;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
|
|
@ -205,7 +212,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 80,
|
||||
height: bottomGradientHeight,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
|
|
@ -225,7 +232,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||
opacity: showContent ? 1.0 : 0.0,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 60),
|
||||
padding: EdgeInsets.only(top: coverTopPadding),
|
||||
child: Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
|
|
@ -252,7 +259,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
|
|||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(
|
||||
Icons.playlist_play,
|
||||
size: 64,
|
||||
size: fallbackIconSize,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
|
|
@ -308,6 +309,20 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||
String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg'
|
||||
String _sortMode = 'latest'; // 'latest', 'oldest', 'a-z', 'z-a'
|
||||
|
||||
double _effectiveTextScale() {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
if (textScale < 1.0) return 1.0;
|
||||
if (textScale > 1.4) return 1.4;
|
||||
return textScale;
|
||||
}
|
||||
|
||||
double _queueCoverSize() {
|
||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
final scale = (shortestSide / 390).clamp(0.82, 1.0);
|
||||
final textScale = _effectiveTextScale();
|
||||
return (56 * scale * (1 + ((textScale - 1) * 0.12))).clamp(46.0, 56.0);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
|
@ -1550,7 +1565,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||
settingsProvider.select((s) => s.historyFilterMode),
|
||||
);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
final historyStats =
|
||||
_historyStatsCache ??
|
||||
|
|
@ -1632,7 +1647,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||
),
|
||||
|
||||
// Search bar - always at top
|
||||
if (allHistoryItems.isNotEmpty || hasQueueItems)
|
||||
if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
|
|
@ -2963,22 +2978,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
|||
}
|
||||
|
||||
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
|
||||
final coverSize = _queueCoverSize();
|
||||
final memCacheSize = (coverSize * 2).round();
|
||||
|
||||
return item.track.coverUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: item.track.coverUrl!,
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 112,
|
||||
memCacheHeight: 112,
|
||||
memCacheWidth: memCacheSize,
|
||||
memCacheHeight: memCacheSize,
|
||||
cacheManager: CoverCacheManager.instance,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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: () {
|
||||
|
|
|
|||
675
lib/screens/settings/cache_management_page.dart
Normal file
675
lib/screens/settings/cache_management_page.dart
Normal file
|
|
@ -0,0 +1,675 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/cover_cache_manager.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class CacheManagementPage extends ConsumerStatefulWidget {
|
||||
const CacheManagementPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CacheManagementPage> createState() =>
|
||||
_CacheManagementPageState();
|
||||
}
|
||||
|
||||
class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
|
||||
// Keep in sync with ExploreNotifier keys.
|
||||
static const String _exploreCacheKey = 'explore_home_feed_cache';
|
||||
static const String _exploreCacheTsKey = 'explore_home_feed_ts';
|
||||
|
||||
_CacheOverview? _overview;
|
||||
bool _isLoading = true;
|
||||
String? _busyAction;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshOverview();
|
||||
}
|
||||
|
||||
bool get _isBusy => _busyAction != null;
|
||||
|
||||
Future<void> _refreshOverview() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final overview = await _buildOverview();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_overview = overview;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<_CacheOverview> _buildOverview() async {
|
||||
final appCacheDir = await getApplicationCacheDirectory();
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appCachePath = p.normalize(appCacheDir.path);
|
||||
final tempPath = p.normalize(tempDir.path);
|
||||
final tempIsSameAsAppCache = appCachePath == tempPath;
|
||||
|
||||
final appCacheStats = await _scanDirectory(Directory(appCachePath));
|
||||
final tempStats = tempIsSameAsAppCache
|
||||
? null
|
||||
: await _scanDirectory(Directory(tempPath));
|
||||
final coverStats = await CoverCacheManager.getStats();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final explorePayload = prefs.getString(_exploreCacheKey);
|
||||
final exploreTs = prefs.getInt(_exploreCacheTsKey);
|
||||
var exploreBytes = 0;
|
||||
if (explorePayload != null && explorePayload.isNotEmpty) {
|
||||
exploreBytes += utf8.encode(explorePayload).length;
|
||||
}
|
||||
if (exploreTs != null) {
|
||||
exploreBytes += 8;
|
||||
}
|
||||
final hasExploreCache = exploreBytes > 0;
|
||||
|
||||
int trackCacheEntries;
|
||||
try {
|
||||
trackCacheEntries = await PlatformBridge.getTrackCacheSize();
|
||||
} catch (_) {
|
||||
trackCacheEntries = 0;
|
||||
}
|
||||
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
||||
final libraryCoverStats = await _scanDirectory(libraryCoverDir);
|
||||
|
||||
return _CacheOverview(
|
||||
appCachePath: appCachePath,
|
||||
appCacheStats: appCacheStats,
|
||||
tempPath: tempIsSameAsAppCache ? null : tempPath,
|
||||
tempStats: tempStats,
|
||||
tempIsSameAsAppCache: tempIsSameAsAppCache,
|
||||
coverStats: coverStats,
|
||||
libraryCoverStats: libraryCoverStats,
|
||||
exploreCacheBytes: exploreBytes,
|
||||
hasExploreCache: hasExploreCache,
|
||||
trackCacheEntries: trackCacheEntries,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_DirectoryStats> _scanDirectory(Directory directory) async {
|
||||
if (!await directory.exists()) {
|
||||
return const _DirectoryStats(fileCount: 0, totalSizeBytes: 0);
|
||||
}
|
||||
|
||||
var fileCount = 0;
|
||||
var totalSize = 0;
|
||||
|
||||
try {
|
||||
await for (final entity in directory.list(
|
||||
recursive: true,
|
||||
followLinks: false,
|
||||
)) {
|
||||
if (entity is File) {
|
||||
fileCount++;
|
||||
totalSize += await entity.length();
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
|
||||
}
|
||||
|
||||
Future<void> _clearDirectoryContents(String path) async {
|
||||
final directory = Directory(path);
|
||||
if (!await directory.exists()) return;
|
||||
|
||||
try {
|
||||
final entities = directory.listSync(followLinks: false);
|
||||
for (final entity in entities) {
|
||||
try {
|
||||
await entity.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await directory.create(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _clearAppCache() async {
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
await _clearDirectoryContents(cacheDir.path);
|
||||
}
|
||||
|
||||
Future<void> _clearTempCache() async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
await _clearDirectoryContents(tempDir.path);
|
||||
}
|
||||
|
||||
Future<void> _clearCoverCache() async {
|
||||
await CoverCacheManager.clearCache();
|
||||
}
|
||||
|
||||
Future<void> _clearLibraryCoverCache() async {
|
||||
final appSupportDir = await getApplicationSupportDirectory();
|
||||
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
|
||||
await _clearDirectoryContents(libraryCoverDir.path);
|
||||
}
|
||||
|
||||
Future<void> _clearExploreCache() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_exploreCacheKey);
|
||||
await prefs.remove(_exploreCacheTsKey);
|
||||
}
|
||||
|
||||
Future<void> _clearTrackCache() async {
|
||||
await PlatformBridge.clearTrackCache();
|
||||
}
|
||||
|
||||
Future<void> _clearAllCaches() async {
|
||||
final currentOverview = _overview;
|
||||
await _clearAppCache();
|
||||
if (currentOverview != null && !currentOverview.tempIsSameAsAppCache) {
|
||||
await _clearTempCache();
|
||||
}
|
||||
await _clearCoverCache();
|
||||
await _clearLibraryCoverCache();
|
||||
await _clearExploreCache();
|
||||
await _clearTrackCache();
|
||||
}
|
||||
|
||||
Future<bool> _confirmClear(String target) async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.cacheClearConfirmTitle),
|
||||
content: Text(context.l10n.cacheClearConfirmMessage(target)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogClear),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirm == true;
|
||||
}
|
||||
|
||||
Future<bool> _confirmClearAll() async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.cacheClearAllConfirmTitle),
|
||||
content: Text(context.l10n.cacheClearAllConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.dialogCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.dialogClear),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return confirm == true;
|
||||
}
|
||||
|
||||
Future<void> _runAction(
|
||||
String actionKey,
|
||||
Future<void> Function() action, {
|
||||
String? successMessage,
|
||||
}) async {
|
||||
if (_isBusy || !mounted) return;
|
||||
setState(() => _busyAction = actionKey);
|
||||
|
||||
try {
|
||||
await action();
|
||||
if (!mounted) return;
|
||||
if (successMessage != null && successMessage.isNotEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(successMessage)));
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $e')));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _busyAction = null);
|
||||
await _refreshOverview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmAndRunAction({
|
||||
required String actionKey,
|
||||
required String targetLabel,
|
||||
required Future<void> Function() action,
|
||||
}) async {
|
||||
final confirmed = await _confirmClear(targetLabel);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (!mounted) return;
|
||||
await _runAction(
|
||||
actionKey,
|
||||
action,
|
||||
successMessage: context.l10n.cacheClearSuccess(targetLabel),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _cleanupUnusedData() async {
|
||||
await _runAction('cleanup_unused', () async {
|
||||
final orphanedDownloads = await ref
|
||||
.read(downloadHistoryProvider.notifier)
|
||||
.cleanupOrphanedDownloads();
|
||||
final missingLibraryEntries = await ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.cleanupMissingFiles();
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.cacheCleanupResult(
|
||||
orphanedDownloads,
|
||||
missingLibraryEntries,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
String _formatDirectorySize(_DirectoryStats stats) {
|
||||
if (stats.fileCount == 0 || stats.totalSizeBytes == 0) {
|
||||
return context.l10n.cacheNoData;
|
||||
}
|
||||
return context.l10n.cacheSizeWithFiles(
|
||||
_formatBytes(stats.totalSizeBytes),
|
||||
stats.fileCount,
|
||||
);
|
||||
}
|
||||
|
||||
String _buildSubtitle(String description, String sizeInfo) {
|
||||
return '$description\n$sizeInfo';
|
||||
}
|
||||
|
||||
Widget _buildClearTrailing(String actionKey, VoidCallback onPressed) {
|
||||
if (_busyAction == actionKey) {
|
||||
return const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
|
||||
return TextButton(
|
||||
onPressed: _isBusy ? null : onPressed,
|
||||
child: Text(context.l10n.dialogClear),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final overview = _overview;
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _isBusy ? null : _refreshOverview,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio =
|
||||
((constraints.maxHeight - minHeight) /
|
||||
(maxHeight - minHeight))
|
||||
.clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
context.l10n.cacheTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (_isLoading || overview == null)
|
||||
const SliverFillRemaining(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else ...[
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 4),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.28),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.cacheSummaryTitle,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
context.l10n.cacheEstimatedTotal(
|
||||
_formatBytes(overview.totalKnownDiskCacheBytes),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.l10n.cacheSummarySubtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onPrimaryContainer.withValues(
|
||||
alpha: 0.85,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: _isBusy
|
||||
? null
|
||||
: () async {
|
||||
final l10n = context.l10n;
|
||||
final confirmed = await _confirmClearAll();
|
||||
if (!confirmed) return;
|
||||
if (!mounted) return;
|
||||
await _runAction(
|
||||
'clear_all',
|
||||
_clearAllCaches,
|
||||
successMessage: l10n.cacheClearSuccess(
|
||||
l10n.cacheClearAll,
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.delete_sweep_outlined),
|
||||
label: Text(context.l10n.cacheClearAll),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isBusy ? null : _refreshOverview,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(context.l10n.cacheRefreshStats),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.cacheSectionStorage,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.folder_outlined,
|
||||
title: context.l10n.cacheAppDirectory,
|
||||
subtitle: _buildSubtitle(
|
||||
context.l10n.cacheAppDirectoryDesc,
|
||||
_formatDirectorySize(overview.appCacheStats),
|
||||
),
|
||||
trailing: _buildClearTrailing(
|
||||
'clear_app_cache',
|
||||
() => _confirmAndRunAction(
|
||||
actionKey: 'clear_app_cache',
|
||||
targetLabel: context.l10n.cacheAppDirectory,
|
||||
action: _clearAppCache,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!overview.tempIsSameAsAppCache &&
|
||||
overview.tempStats != null)
|
||||
SettingsItem(
|
||||
icon: Icons.timer_outlined,
|
||||
title: context.l10n.cacheTempDirectory,
|
||||
subtitle: _buildSubtitle(
|
||||
context.l10n.cacheTempDirectoryDesc,
|
||||
_formatDirectorySize(overview.tempStats!),
|
||||
),
|
||||
trailing: _buildClearTrailing(
|
||||
'clear_temp_cache',
|
||||
() => _confirmAndRunAction(
|
||||
actionKey: 'clear_temp_cache',
|
||||
targetLabel: context.l10n.cacheTempDirectory,
|
||||
action: _clearTempCache,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.image_outlined,
|
||||
title: context.l10n.cacheCoverImage,
|
||||
subtitle: _buildSubtitle(
|
||||
context.l10n.cacheCoverImageDesc,
|
||||
overview.coverStats.fileCount > 0 &&
|
||||
overview.coverStats.totalSizeBytes > 0
|
||||
? context.l10n.cacheSizeWithFiles(
|
||||
_formatBytes(overview.coverStats.totalSizeBytes),
|
||||
overview.coverStats.fileCount,
|
||||
)
|
||||
: context.l10n.cacheNoData,
|
||||
),
|
||||
trailing: _buildClearTrailing(
|
||||
'clear_cover_cache',
|
||||
() => _confirmAndRunAction(
|
||||
actionKey: 'clear_cover_cache',
|
||||
targetLabel: context.l10n.cacheCoverImage,
|
||||
action: _clearCoverCache,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.library_music_outlined,
|
||||
title: context.l10n.cacheLibraryCover,
|
||||
subtitle: _buildSubtitle(
|
||||
context.l10n.cacheLibraryCoverDesc,
|
||||
overview.libraryCoverStats.fileCount > 0 &&
|
||||
overview.libraryCoverStats.totalSizeBytes > 0
|
||||
? context.l10n.cacheSizeWithFiles(
|
||||
_formatBytes(
|
||||
overview.libraryCoverStats.totalSizeBytes,
|
||||
),
|
||||
overview.libraryCoverStats.fileCount,
|
||||
)
|
||||
: context.l10n.cacheNoData,
|
||||
),
|
||||
trailing: _buildClearTrailing(
|
||||
'clear_library_cover_cache',
|
||||
() => _confirmAndRunAction(
|
||||
actionKey: 'clear_library_cover_cache',
|
||||
targetLabel: context.l10n.cacheLibraryCover,
|
||||
action: _clearLibraryCoverCache,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.explore_outlined,
|
||||
title: context.l10n.cacheExploreFeed,
|
||||
subtitle: _buildSubtitle(
|
||||
context.l10n.cacheExploreFeedDesc,
|
||||
overview.hasExploreCache
|
||||
? context.l10n.cacheSizeOnly(
|
||||
_formatBytes(overview.exploreCacheBytes),
|
||||
)
|
||||
: context.l10n.cacheNoData,
|
||||
),
|
||||
trailing: _buildClearTrailing(
|
||||
'clear_explore_cache',
|
||||
() => _confirmAndRunAction(
|
||||
actionKey: 'clear_explore_cache',
|
||||
targetLabel: context.l10n.cacheExploreFeed,
|
||||
action: _clearExploreCache,
|
||||
),
|
||||
),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.memory_outlined,
|
||||
title: context.l10n.cacheTrackLookup,
|
||||
subtitle: _buildSubtitle(
|
||||
context.l10n.cacheTrackLookupDesc,
|
||||
overview.trackCacheEntries > 0
|
||||
? context.l10n.cacheEntries(overview.trackCacheEntries)
|
||||
: context.l10n.cacheNoData,
|
||||
),
|
||||
trailing: _buildClearTrailing(
|
||||
'clear_track_cache',
|
||||
() => _confirmAndRunAction(
|
||||
actionKey: 'clear_track_cache',
|
||||
targetLabel: context.l10n.cacheTrackLookup,
|
||||
action: _clearTrackCache,
|
||||
),
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.cacheSectionMaintenance,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
title: context.l10n.cacheCleanupUnused,
|
||||
subtitle: '${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
|
||||
trailing: _buildClearTrailing(
|
||||
'cleanup_unused',
|
||||
_cleanupUnusedData,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheOverview {
|
||||
final String appCachePath;
|
||||
final _DirectoryStats appCacheStats;
|
||||
final String? tempPath;
|
||||
final _DirectoryStats? tempStats;
|
||||
final bool tempIsSameAsAppCache;
|
||||
final CacheStats coverStats;
|
||||
final _DirectoryStats libraryCoverStats;
|
||||
final int exploreCacheBytes;
|
||||
final bool hasExploreCache;
|
||||
final int trackCacheEntries;
|
||||
|
||||
const _CacheOverview({
|
||||
required this.appCachePath,
|
||||
required this.appCacheStats,
|
||||
this.tempPath,
|
||||
this.tempStats,
|
||||
required this.tempIsSameAsAppCache,
|
||||
required this.coverStats,
|
||||
required this.libraryCoverStats,
|
||||
required this.exploreCacheBytes,
|
||||
required this.hasExploreCache,
|
||||
required this.trackCacheEntries,
|
||||
});
|
||||
|
||||
int get totalKnownDiskCacheBytes {
|
||||
return appCacheStats.totalSizeBytes +
|
||||
(tempStats?.totalSizeBytes ?? 0) +
|
||||
coverStats.totalSizeBytes +
|
||||
libraryCoverStats.totalSizeBytes +
|
||||
exploreCacheBytes;
|
||||
}
|
||||
}
|
||||
|
||||
class _DirectoryStats {
|
||||
final int fileCount;
|
||||
final int totalSizeBytes;
|
||||
|
||||
const _DirectoryStats({
|
||||
required this.fileCount,
|
||||
required this.totalSizeBytes,
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class DownloadSettingsPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -93,7 +94,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||
Widget build(BuildContext context) {
|
||||
final settings = ref.watch(settingsProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
final isBuiltInService = _builtInServices.contains(settings.defaultService);
|
||||
final isTidalService = settings.defaultService == 'tidal';
|
||||
|
|
@ -346,7 +347,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
|
|||
ref,
|
||||
settings.folderOrganization,
|
||||
),
|
||||
showDivider: false,
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.person_search_outlined,
|
||||
title: context.l10n.downloadUseAlbumArtistForFolders,
|
||||
subtitle: settings.useAlbumArtistForFolders
|
||||
? context
|
||||
.l10n
|
||||
.downloadUseAlbumArtistForFoldersAlbumSubtitle
|
||||
: context
|
||||
.l10n
|
||||
.downloadUseAlbumArtistForFoldersTrackSubtitle,
|
||||
value: settings.useAlbumArtistForFolders,
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setUseAlbumArtistForFolders(value),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class ExtensionDetailPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -55,7 +56,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
|
|||
);
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final hasError = extension.status == 'error';
|
||||
|
||||
return PopScope(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
|
|||
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/metadata_provider_priority_page.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class ExtensionsPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -51,7 +52,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
|
|||
Widget build(BuildContext context) {
|
||||
final extState = ref.watch(extensionProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class LibrarySettingsPage extends ConsumerStatefulWidget {
|
||||
|
|
@ -30,7 +31,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||
// -> /storage/emulated/0/Music
|
||||
try {
|
||||
final uri = Uri.parse(path);
|
||||
final treePath = uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
|
||||
final treePath =
|
||||
uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic"
|
||||
final decoded = Uri.decodeComponent(treePath);
|
||||
if (decoded.startsWith('primary:')) {
|
||||
return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
|
||||
|
|
@ -156,10 +158,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||
return;
|
||||
}
|
||||
|
||||
await ref.read(localLibraryProvider.notifier).startScan(
|
||||
libraryPath,
|
||||
forceFullScan: forceFullScan,
|
||||
);
|
||||
await ref
|
||||
.read(localLibraryProvider.notifier)
|
||||
.startScan(libraryPath, forceFullScan: forceFullScan);
|
||||
}
|
||||
|
||||
Future<void> _cancelScan() async {
|
||||
|
|
@ -216,7 +217,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||
final settings = ref.watch(settingsProvider);
|
||||
final libraryState = ref.watch(localLibraryProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
|
|
@ -260,6 +261,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||
SliverToBoxAdapter(
|
||||
child: _LibraryHeroCard(
|
||||
itemCount: libraryState.items.length,
|
||||
excludedDownloadedCount: libraryState.excludedDownloadedCount,
|
||||
isScanning: libraryState.isScanning,
|
||||
scanProgress: libraryState.scanProgress,
|
||||
scanCurrentFile: libraryState.scanCurrentFile,
|
||||
|
|
@ -331,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6),
|
||||
color: colorScheme.tertiaryContainer.withValues(
|
||||
alpha: 0.6,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
|
|
@ -347,17 +351,20 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||
children: [
|
||||
Text(
|
||||
'Scan cancelled',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'You can retry the scan when ready.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8),
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: colorScheme.onTertiaryContainer
|
||||
.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -493,6 +500,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
|||
|
||||
class _LibraryHeroCard extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final int excludedDownloadedCount;
|
||||
final bool isScanning;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
|
|
@ -502,6 +510,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||
|
||||
const _LibraryHeroCard({
|
||||
required this.itemCount,
|
||||
required this.excludedDownloadedCount,
|
||||
required this.isScanning,
|
||||
required this.scanProgress,
|
||||
this.scanCurrentFile,
|
||||
|
|
@ -527,10 +536,13 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final displayCount = isScanning
|
||||
? scannedFiles
|
||||
: itemCount + excludedDownloadedCount;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
height: 220,
|
||||
constraints: const BoxConstraints(minHeight: 220),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
|
|
@ -626,12 +638,12 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: 16),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
isScanning ? scannedFiles.toString() : itemCount.toString(),
|
||||
displayCount.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
|
|
@ -644,17 +656,35 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isScanning
|
||||
? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim()
|
||||
? context.l10n
|
||||
.libraryTracksCount(scannedFiles)
|
||||
.replaceAll(scannedFiles.toString(), '')
|
||||
.trim()
|
||||
: context.l10n
|
||||
.libraryTracksCount(itemCount)
|
||||
.replaceAll(itemCount.toString(), '')
|
||||
.trim(),
|
||||
.libraryTracksCount(displayCount)
|
||||
.replaceAll(displayCount.toString(), '')
|
||||
.trim(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (!isScanning && excludedDownloadedCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$excludedDownloadedCount from Downloads history '
|
||||
'(excluded from list)',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isScanning && scanCurrentFile != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
LinearProgressIndicator(
|
||||
|
|
@ -670,7 +700,9 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||
Icon(
|
||||
Icons.history,
|
||||
size: 14,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
|
|
@ -679,7 +711,9 @@ class _LibraryHeroCard extends StatelessWidget {
|
|||
),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
color: colorScheme.onSurfaceVariant.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
|
|
@ -126,7 +127,7 @@ class _LogScreenState extends State<LogScreen> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
final logs = _filteredLogs;
|
||||
|
||||
return PopScope(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const MetadataProviderPriorityPage({super.key});
|
||||
|
|
@ -40,7 +41,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:spotiflac_android/models/settings.dart';
|
|||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class OptionsSettingsPage extends ConsumerWidget {
|
||||
|
|
@ -16,7 +17,7 @@ class OptionsSettingsPage extends ConsumerWidget {
|
|||
final extensionState = ref.watch(extensionProvider);
|
||||
final hasExtensions = extensionState.extensions.isNotEmpty;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: true, // Always allow back gesture
|
||||
|
|
@ -958,6 +959,27 @@ class _MetadataSourceSelector extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
],
|
||||
if (currentSource == 'spotify' && !hasExtensionSearch) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 16,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.optionsSpotifyDeprecationWarning,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/extension_provider.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
class ProviderPriorityPage extends ConsumerStatefulWidget {
|
||||
const ProviderPriorityPage({super.key});
|
||||
|
|
@ -40,7 +41,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: !_hasChanges,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -248,7 +248,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.setupUseDefaultFolder),
|
||||
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'),
|
||||
content: Text(
|
||||
'${context.l10n.setupNoFolderSelected}\n\n$defaultDir',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
|
|
@ -576,37 +578,60 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
|
|||
}
|
||||
|
||||
Widget _buildWelcomeStep(ColorScheme colorScheme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
width: 104,
|
||||
height: 104,
|
||||
color: colorScheme.primary,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
context.l10n.appName,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
final textScale = MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(1.0).clamp(1.0, 1.4);
|
||||
final logoSize = (shortestSide * 0.24).clamp(80.0, 104.0);
|
||||
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
|
||||
final subtitleGap = (shortestSide * 0.04).clamp(8.0, 16.0);
|
||||
final minContentHeight = constraints.maxHeight > 48
|
||||
? constraints.maxHeight - 48
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: minContentHeight),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo-transparant.png',
|
||||
width: logoSize,
|
||||
height: logoSize,
|
||||
color: colorScheme.primary,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(height: titleGap),
|
||||
Text(
|
||||
context.l10n.appName,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
fontSize:
|
||||
(Theme.of(context).textTheme.displaySmall?.fontSize ??
|
||||
36) *
|
||||
(1 + ((textScale - 1) * 0.18)),
|
||||
),
|
||||
),
|
||||
SizedBox(height: subtitleGap),
|
||||
Text(
|
||||
context.l10n.setupDownloadInFlac,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.setupDownloadInFlac,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -833,41 +858,58 @@ class _StepLayout extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
|
||||
final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
|
||||
final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
|
||||
final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0);
|
||||
final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0);
|
||||
final minContentHeight = constraints.maxHeight > 48
|
||||
? constraints.maxHeight - 48
|
||||
: 0.0;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: minContentHeight),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(iconPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: iconSize, color: colorScheme.primary),
|
||||
),
|
||||
SizedBox(height: titleGap),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: descriptionGap),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: actionGap),
|
||||
child,
|
||||
],
|
||||
),
|
||||
child: Icon(icon, size: 48, color: colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -881,21 +923,25 @@ class _SuccessCard extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
|
|||
import 'package:spotiflac_android/providers/store_provider.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
|
||||
import 'package:spotiflac_android/utils/app_bar_layout.dart';
|
||||
|
||||
class StoreTab extends ConsumerStatefulWidget {
|
||||
const StoreTab({super.key});
|
||||
|
|
@ -44,7 +45,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
|
|||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(storeProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final topPadding = normalizedHeaderTopPadding(context);
|
||||
|
||||
return Scaffold(
|
||||
body: RefreshIndicator(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,26 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||
int _currentPage = 0;
|
||||
static const int _totalPages = 6;
|
||||
|
||||
double _responsiveScale({
|
||||
required BuildContext context,
|
||||
double min = 0.82,
|
||||
double max = 1.08,
|
||||
double baseShortestSide = 390,
|
||||
}) {
|
||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
final scale = shortestSide / baseShortestSide;
|
||||
if (scale < min) return min;
|
||||
if (scale > max) return max;
|
||||
return scale;
|
||||
}
|
||||
|
||||
double _effectiveTextScale(BuildContext context) {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
if (textScale < 1.0) return 1.0;
|
||||
if (textScale > 1.4) return 1.4;
|
||||
return textScale;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
|
|
@ -55,6 +75,15 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final l10n = context.l10n;
|
||||
final isLastPage = _currentPage == _totalPages - 1;
|
||||
final scale = _responsiveScale(context: context, min: 0.86, max: 1.05);
|
||||
final textScale = _effectiveTextScale(context);
|
||||
final topBarPaddingH = 24 * scale;
|
||||
final topBarPaddingV = 16 * scale;
|
||||
final pageIndicatorHeight = 8 * scale;
|
||||
final pageIndicatorWidth = 8 * scale;
|
||||
final activeIndicatorWidth = 32 * scale;
|
||||
final bottomGap = (32 * scale) + ((textScale - 1) * 8);
|
||||
final actionButtonHeight = (56 * scale) + ((textScale - 1) * 6);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
|
|
@ -63,7 +92,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||
children: [
|
||||
// Top Navigation Bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: topBarPaddingH,
|
||||
vertical: topBarPaddingV,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
|
@ -199,9 +231,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOutBack,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
height: 8,
|
||||
width: isActive ? 32 : 8,
|
||||
margin: EdgeInsets.symmetric(horizontal: 4 * scale),
|
||||
height: pageIndicatorHeight,
|
||||
width: isActive
|
||||
? activeIndicatorWidth
|
||||
: pageIndicatorWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? colorScheme.primary
|
||||
|
|
@ -211,11 +245,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
|
|||
);
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(height: bottomGap),
|
||||
// Action Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
height: actionButtonHeight,
|
||||
child: FilledButton(
|
||||
onPressed: _nextPage,
|
||||
style: FilledButton.styleFrom(
|
||||
|
|
@ -520,104 +554,114 @@ class _InteractiveDownloadExampleState
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: 36,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cardWidth = constraints.maxWidth;
|
||||
final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
|
||||
final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
|
||||
final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 140,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.album_rounded,
|
||||
size: coverSize * 0.5,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_isDownloading)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 12,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: 90,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
GestureDetector(
|
||||
onTap: _startDownload,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: _isCompleted ? Colors.green : colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (_isCompleted ? Colors.green : colorScheme.primary)
|
||||
.withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _isDownloading
|
||||
? SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.onPrimary,
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: (cardWidth * 0.35).clamp(100.0, 160.0),
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurface,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isCompleted
|
||||
? Icons.check_rounded
|
||||
: Icons.download_rounded,
|
||||
color: colorScheme.onPrimary,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_isDownloading)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: _progress,
|
||||
minHeight: 12,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: (cardWidth * 0.22).clamp(70.0, 100.0),
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
GestureDetector(
|
||||
onTap: _startDownload,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
padding: EdgeInsets.all(buttonPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: _isCompleted ? Colors.green : colorScheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
(_isCompleted ? Colors.green : colorScheme.primary)
|
||||
.withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _isDownloading
|
||||
? SizedBox(
|
||||
width: buttonIconSize,
|
||||
height: buttonIconSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_isCompleted
|
||||
? Icons.check_rounded
|
||||
: Icons.download_rounded,
|
||||
color: colorScheme.onPrimary,
|
||||
size: buttonIconSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -644,6 +688,18 @@ class _TutorialPage extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
|
||||
final textScale = MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(1.0).clamp(1.0, 1.4);
|
||||
final scale = (shortestSide / 390).clamp(0.86, 1.05);
|
||||
final topGap = (24 * scale).clamp(16.0, 24.0);
|
||||
final iconPadding = (24 * scale).clamp(18.0, 24.0);
|
||||
final iconSize = (56 * scale).clamp(44.0, 56.0);
|
||||
final iconTextGap = (48 * scale).clamp(28.0, 48.0);
|
||||
final descriptionGap = (20 * scale).clamp(12.0, 20.0);
|
||||
final contentGap = (56 * scale) + ((textScale - 1) * 10);
|
||||
final bottomGap = (32 * scale).clamp(20.0, 32.0);
|
||||
|
||||
// Parallax effect logic (simplified for StatelessWidget)
|
||||
// In a real advanced implementation we'd pass the Controller's listenable
|
||||
|
|
@ -656,23 +712,23 @@ class _TutorialPage extends StatelessWidget {
|
|||
physics: const BouncingScrollPhysics(),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(height: topGap),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOutBack,
|
||||
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
|
||||
padding: const EdgeInsets.all(24),
|
||||
padding: EdgeInsets.all(iconPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 56,
|
||||
size: iconSize,
|
||||
color: iconColor ?? colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
SizedBox(height: iconTextGap),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: isActive ? 1.0 : 0.0,
|
||||
|
|
@ -687,7 +743,7 @@ class _TutorialPage extends StatelessWidget {
|
|||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(height: descriptionGap),
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
opacity: isActive ? 1.0 : 0.0,
|
||||
|
|
@ -697,14 +753,14 @@ class _TutorialPage extends StatelessWidget {
|
|||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
height: 1.5,
|
||||
fontSize: 16,
|
||||
fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 56),
|
||||
SizedBox(height: contentGap),
|
||||
content, // The content itself now handles its own internal animations
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(height: bottomGap),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ final _log = AppLogger('FFmpeg');
|
|||
|
||||
class FFmpegService {
|
||||
static const int _commandLogPreviewLength = 300;
|
||||
static int _tempEmbedCounter = 0;
|
||||
|
||||
static String _buildOutputPath(String inputPath, String extension) {
|
||||
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
|
||||
|
|
@ -47,6 +48,14 @@ class FFmpegService {
|
|||
return '${redacted.substring(0, _commandLogPreviewLength)}...';
|
||||
}
|
||||
|
||||
static String _nextTempEmbedPath(String tempDirPath, String extension) {
|
||||
final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
|
||||
_tempEmbedCounter = (_tempEmbedCounter + 1) & 0x7fffffff;
|
||||
final timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
final processId = pid;
|
||||
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
|
||||
}
|
||||
|
||||
static Future<FFmpegResult> _execute(String command) async {
|
||||
try {
|
||||
final session = await FFmpegKit.execute(command);
|
||||
|
|
@ -269,8 +278,7 @@ class FFmpegService {
|
|||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$flacPath" ');
|
||||
|
|
@ -347,8 +355,7 @@ class FFmpegService {
|
|||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$mp3Path" ');
|
||||
|
|
@ -429,8 +436,7 @@ class FFmpegService {
|
|||
Map<String, String>? metadata,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
|
||||
|
||||
final StringBuffer cmdBuffer = StringBuffer();
|
||||
cmdBuffer.write('-i "$opusPath" ');
|
||||
|
|
|
|||
17
lib/utils/app_bar_layout.dart
Normal file
17
lib/utils/app_bar_layout.dart
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
const double kNormalizedHeaderTopPadding = 24.0;
|
||||
|
||||
double normalizedHeaderTopPadding(
|
||||
BuildContext context, {
|
||||
double max = kNormalizedHeaderTopPadding,
|
||||
}) {
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS) {
|
||||
return 0;
|
||||
}
|
||||
final topPadding = MediaQuery.paddingOf(context).top;
|
||||
if (topPadding <= 0) return 0;
|
||||
return topPadding > max ? max : topPadding;
|
||||
}
|
||||
|
|
@ -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: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue