Compare commits

...

6 Commits

Author SHA1 Message Date
zarzet 3a6b7eed59 perf: swap SpotubeDL as primary YouTube provider, Cobalt as fallback 2026-02-10 00:47:44 +07:00
zarzet 51d02d7764 chore: bump app_info version to 3.6.0+77 2026-02-09 23:36:34 +07:00
zarzet df39d61ed4 feat: save cover art, save lyrics, re-enrich metadata with full SAF support + YouTube Cobalt provider with SpotubeDL fallback + metadata summary logging 2026-02-09 23:07:18 +07:00
zarzet 7ec5d28caf feat: add YouTube provider for lossy downloads via Cobalt API
- New YouTube download provider with Opus 256kbps and MP3 320kbps options
- SongLink/Odesli integration for Spotify/Deezer ID to YouTube URL conversion
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during download
- Queue progress shows bytes (X.X MB) for streaming downloads
- Full metadata embedding: cover, lyrics, title, artist, album, track#, disc#, year, ISRC
- Removed Tidal HIGH (lossy AAC) option - use YouTube for lossy instead
- Bumped version to 3.6.0
2026-02-09 18:15:43 +07:00
zarzet 23f5aa11b0 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
2026-02-09 15:58:50 +07:00
zarzet 5fdf1df5df feat: cross-script transliteration matching for Tidal/Qobuz and skip-downloaded option for CSV import 2026-02-09 10:57:52 +07:00
65 changed files with 9496 additions and 1559 deletions
+83
View File
@@ -1,5 +1,88 @@
# Changelog # Changelog
## [3.6.0] - 2026-02-09
### Highlights
- **YouTube Provider (Lossy)**: New download option via Cobalt API for tracks not available on lossless services
- Opus 256kbps (recommended) or MP3 320kbps quality options
- Full metadata embedding: cover art, title, artist, album, track/disc number, year, ISRC
- Lyrics fetching from lrclib.net with embed and external .lrc support
- Works as fallback when Tidal/Qobuz/Amazon downloads fail
- **Edit Metadata**: Edit embedded metadata directly from the Track Metadata screen (FLAC, MP3, Opus)
- Editable fields: Title, Artist, Album, Album Artist, Date, Track#, Disc#, Genre, ISRC
- Advanced fields: Label, Copyright, Composer, Comment
- FLAC: native Go writer, MP3/Opus: FFmpeg-based writer
- UI refreshes in-place after save without needing to re-open the screen
- iOS and Android support
### Added
- Save Cover Art: download high-quality album art as standalone .jpg from track metadata screen
- Save Lyrics (.lrc): fetch and save lyrics as standalone .lrc file without downloading the song
- Re-enrich Metadata: re-embed metadata, cover art, and lyrics into existing audio files without re-downloading (FLAC native, MP3/Opus via FFmpeg)
- Re-enrich now supports local library items: searches Spotify/Deezer by track name + artist to fetch complete metadata from the internet, then embeds cover art, lyrics, genre, label, and all tags into the file
- YouTube download provider using Cobalt API with SongLink/Odesli integration for Spotify/Deezer ID → YouTube URL conversion
- SpotubeDL as fallback Cobalt proxy when primary API fails
- YouTube video ID detection for YT Music extension compatibility
- Parallel cover art and lyrics fetching during YouTube download
- Queue progress now shows "X.X MB" instead of "0%" for streaming downloads where total size is unknown (Cobalt tunnel mode)
- Full metadata pipeline for YouTube downloads: cover art, lyrics, title, artist, album, track#, disc#, year, ISRC
### Changed
- Removed Tidal HIGH (lossy AAC) quality option - use YouTube provider for lossy downloads instead
- Simplified download service picker by removing dead lossy format code
- Removed Amazon from download settings UI (now only used as automatic fallback)
- Cleaned up dead disabled-chip code in download service selector
### Fixed
- Fixed `error.api.youtube.login` by using YouTube Music URLs instead of regular YouTube URLs for Cobalt requests
- Fixed SongLink to prioritize `youtubeMusic` platform URL over `youtube` for Cobalt compatibility
- Fixed YouTube metadata not being overwritten by setting `DisableMetadata: true` in Cobalt requests
- Fixed ISRC validation in metadata enrichment flow - invalid ISRCs no longer trigger failed Deezer lookups
- Fixed YouTube metadata enrichment to work like other providers (SongLink Deezer ID extraction, proper metadata embedding)
- Go metadata parsers now read Composer, Comment, Label, Copyright from FLAC, MP3 (ID3v2.2/v2.3/v2.4), and Opus/OGG files
- Added proper COMM frame parser for ID3v2 (handles language code + description prefix correctly)
- Fixed Re-enrich Metadata failing on SAF storage files (`content://` URIs) - Kotlin now copies SAF file to temp, Go processes temp file, then writes back for FLAC or returns temp path for FFmpeg (MP3/Opus)
- Fixed Save Cover Art and Save Lyrics crashing on SAF-stored download history items - now saves to temp then writes to SAF tree via `createSafFileFromPath`
- Fixed `_getFileDirectory()` crash when called with `content://` URI by adding SAF guard
- Fixed `readAudioMetadata` Kotlin handler not handling SAF URIs - now copies to temp for reading
- Added metadata summary log in Re-enrich flow showing all fields before embedding (title, artist, album, track#, disc#, date, ISRC, genre, label)
---
## [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 ## [3.5.2] - 2026-02-08
### Performance ### Performance
+1
View File
@@ -98,6 +98,7 @@ The software is provided "as is", without warranty of any kind. The author assum
- **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev) - **Qobuz**: [dabmusic.xyz](https://dabmusic.xyz), [squid.wtf](https://squid.wtf), [jumo-dl](https://jumo-dl.pages.dev)
- **Amazon**: [AfkarXYZ](https://github.com/afkarxyz) - **Amazon**: [AfkarXYZ](https://github.com/afkarxyz)
- **Lyrics**: [LRCLib](https://lrclib.net) - **Lyrics**: [LRCLib](https://lrclib.net)
- **YouTube Audio**: [Cobalt](https://cobalt.tools) via [qwkuns.me](https://qwkuns.me), [SpotubeDL](https://spotubedl.com)
- **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify) - **Track Linking**: [SongLink / Odesli](https://odesli.co), [IDHS](https://github.com/sjdonado/idonthavespotify)
@@ -1597,7 +1597,182 @@ class MainActivity: FlutterFragmentActivity() {
"readFileMetadata" -> { "readFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.readFileMetadata(filePath) try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.readFileMetadata(tempPath)
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.readFileMetadata(filePath)
}
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "readFileMetadata failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"editFileMetadata" -> {
val filePath = call.argument<String>("file_path") ?: ""
val metadataJson = call.argument<String>("metadata_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
val raw = Gobackend.editFileMetadata(tempPath, metadataJson)
val obj = JSONObject(raw)
val method = obj.optString("method", "")
if (method == "ffmpeg") {
// MP3/Opus: Dart needs to FFmpeg the temp file, then call writeTempToSaf
obj.put("temp_path", tempPath)
obj.put("saf_uri", filePath)
return@withContext obj.toString()
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
}
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
throw e
}
} else {
Gobackend.editFileMetadata(filePath, metadataJson)
}
} catch (e: Exception) {
android.util.Log.e("SpotiFLAC", "editFileMetadata failed: ${e.message}", e)
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"writeTempToSaf" -> {
val tempPath = call.argument<String>("temp_path") ?: ""
val safUri = call.argument<String>("saf_uri") ?: ""
val response = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(safUri)
if (writeUriFromPath(uri, tempPath)) {
"""{"success":true}"""
} else {
"""{"success":false,"error":"Failed to write back to SAF"}"""
}
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
}
result.success(response)
}
"downloadCoverToFile" -> {
val coverUrl = call.argument<String>("cover_url") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val maxQuality = call.argument<Boolean>("max_quality") ?: true
val response = withContext(Dispatchers.IO) {
try {
Gobackend.downloadCoverToFile(coverUrl, outputPath, maxQuality)
"""{"success":true}"""
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"extractCoverToFile" -> {
val audioPath = call.argument<String>("audio_path") ?: ""
val outputPath = call.argument<String>("output_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
if (audioPath.startsWith("content://")) {
val uri = Uri.parse(audioPath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"success":false,"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.extractCoverToFile(tempPath, outputPath)
"""{"success":true}"""
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.extractCoverToFile(audioPath, outputPath)
"""{"success":true}"""
}
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"fetchAndSaveLyrics" -> {
val trackName = call.argument<String>("track_name") ?: ""
val artistName = call.argument<String>("artist_name") ?: ""
val spotifyId = call.argument<String>("spotify_id") ?: ""
val durationMs = call.argument<Long>("duration_ms") ?: 0L
val outputPath = call.argument<String>("output_path") ?: ""
val response = withContext(Dispatchers.IO) {
try {
Gobackend.fetchAndSaveLyrics(trackName, artistName, spotifyId, durationMs, outputPath)
"""{"success":true}"""
} catch (e: Exception) {
"""{"success":false,"error":"${e.message?.replace("\"", "'")}"}"""
}
}
result.success(response)
}
"reEnrichFile" -> {
val requestJson = call.argument<String>("request_json") ?: "{}"
val response = withContext(Dispatchers.IO) {
try {
val reqObj = JSONObject(requestJson)
val filePath = reqObj.optString("file_path", "")
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
// Replace file_path with temp path for Go
reqObj.put("file_path", tempPath)
val raw = Gobackend.reEnrichFile(reqObj.toString())
val obj = JSONObject(raw)
if (obj.has("error")) {
return@withContext raw
}
val method = obj.optString("method", "")
if (method == "ffmpeg") {
// MP3/Opus: Dart handles FFmpeg on temp file, then writes back
obj.put("temp_path", tempPath)
obj.put("saf_uri", filePath)
return@withContext obj.toString()
// temp file NOT deleted - Dart cleans up after FFmpeg + writeTempToSaf
}
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write enriched metadata back to SAF file"}"""
}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
throw e
}
} else {
Gobackend.reEnrichFile(requestJson)
}
} catch (e: Exception) {
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
} }
result.success(response) result.success(response)
} }
@@ -1927,6 +2102,15 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"downloadFromYouTube" -> {
val requestJson = call.arguments as String
val response = withContext(Dispatchers.IO) {
handleSafDownload(requestJson) { json ->
Gobackend.downloadFromYouTube(json)
}
}
result.success(response)
}
"enrichTrackWithExtension" -> { "enrichTrackWithExtension" -> {
val extensionId = call.argument<String>("extension_id") ?: "" val extensionId = call.argument<String>("extension_id") ?: ""
val trackJson = call.argument<String>("track") ?: "{}" val trackJson = call.argument<String>("track") ?: "{}"
@@ -2249,7 +2433,22 @@ class MainActivity: FlutterFragmentActivity() {
"readAudioMetadata" -> { "readAudioMetadata" -> {
val filePath = call.argument<String>("file_path") ?: "" val filePath = call.argument<String>("file_path") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
Gobackend.readAudioMetadataJSON(filePath) try {
if (filePath.startsWith("content://")) {
val uri = Uri.parse(filePath)
val tempPath = copyUriToTemp(uri)
?: return@withContext """{"error":"Failed to copy SAF file to temp"}"""
try {
Gobackend.readAudioMetadataJSON(tempPath)
} finally {
try { File(tempPath).delete() } catch (_: Exception) {}
}
} else {
Gobackend.readAudioMetadataJSON(filePath)
}
} catch (e: Exception) {
"""{"error":"${e.message?.replace("\"", "'")}"}"""
}
} }
result.success(response) result.success(response)
} }
+68
View File
@@ -23,6 +23,10 @@ type AudioMetadata struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
Label string
Copyright string
Composer string
Comment string
} }
// MP3Quality represents MP3 specific quality info // MP3Quality represents MP3 specific quality info
@@ -171,6 +175,12 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
metadata.TrackNumber = parseTrackNumber(value) metadata.TrackNumber = parseTrackNumber(value)
case "TPA": case "TPA":
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "TCM":
metadata.Composer = value
case "TPB":
metadata.Label = value
case "TCR":
metadata.Copyright = value
} }
pos += 6 + frameSize pos += 6 + frameSize
@@ -277,6 +287,16 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "TSRC": case "TSRC":
metadata.ISRC = value metadata.ISRC = value
case "TCOM":
metadata.Composer = value
case "TPUB":
metadata.Label = value
case "TCOP":
metadata.Copyright = value
case "COMM":
if v := extractCommentFrame(frameData); v != "" {
metadata.Comment = v
}
} }
pos += 10 + frameSize pos += 10 + frameSize
@@ -339,6 +359,46 @@ func extractTextFrame(data []byte) string {
} }
} }
// extractCommentFrame parses an ID3v2 COMM frame.
// Format: encoding(1) + language(3) + description(null-terminated) + text
func extractCommentFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
// skip 3-byte language code
rest := data[4:]
// find null terminator separating description from text
var text []byte
switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
// re-prepend encoding byte so extractTextFrame can decode properly
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
func decodeUTF16(data []byte) string { func decodeUTF16(data []byte) string {
if len(data) < 2 { if len(data) < 2 {
return "" return ""
@@ -779,6 +839,14 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.DiscNumber = parseTrackNumber(value) metadata.DiscNumber = parseTrackNumber(value)
case "ISRC": case "ISRC":
metadata.ISRC = value metadata.ISRC = value
case "COMPOSER":
metadata.Composer = value
case "COMMENT", "DESCRIPTION":
metadata.Comment = value
case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value
case "COPYRIGHT":
metadata.Copyright = value
} }
} }
} }
+612 -24
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os"
"strings" "strings"
"time" "time"
@@ -267,6 +268,24 @@ func DownloadTrack(requestJSON string) (string, error) {
} }
} }
err = amazonErr err = amazonErr
case "youtube":
youtubeResult, youtubeErr := downloadFromYouTube(req)
if youtubeErr == nil {
result = DownloadResult{
FilePath: youtubeResult.FilePath,
BitDepth: 0, // Lossy format, no bit depth
SampleRate: 0, // Lossy format
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
}
err = youtubeErr
default: default:
return errorResponse("Unknown service: " + req.Service) return errorResponse("Unknown service: " + req.Service)
} }
@@ -538,34 +557,106 @@ func CleanupConnections() {
} }
func ReadFileMetadata(filePath string) (string, error) { func ReadFileMetadata(filePath string) (string, error) {
metadata, err := ReadMetadata(filePath) lower := strings.ToLower(filePath)
if err != nil { isFlac := strings.HasSuffix(lower, ".flac")
return "", fmt.Errorf("failed to read metadata: %w", err) isMp3 := strings.HasSuffix(lower, ".mp3")
} isOgg := strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg")
quality, qualityErr := GetAudioQuality(filePath)
duration := 0
if qualityErr == nil && quality.SampleRate > 0 && quality.TotalSamples > 0 {
duration = int(quality.TotalSamples / int64(quality.SampleRate))
}
result := map[string]interface{}{ result := map[string]interface{}{
"title": metadata.Title, "title": "",
"artist": metadata.Artist, "artist": "",
"album": metadata.Album, "album": "",
"album_artist": metadata.AlbumArtist, "album_artist": "",
"date": metadata.Date, "date": "",
"track_number": metadata.TrackNumber, "track_number": 0,
"disc_number": metadata.DiscNumber, "disc_number": 0,
"isrc": metadata.ISRC, "isrc": "",
"lyrics": metadata.Lyrics, "lyrics": "",
"duration": duration, "genre": "",
"label": "",
"copyright": "",
"composer": "",
"comment": "",
"duration": 0,
} }
if qualityErr == nil { if isFlac {
result["bit_depth"] = quality.BitDepth metadata, err := ReadMetadata(filePath)
result["sample_rate"] = quality.SampleRate if err != nil {
return "", fmt.Errorf("failed to read metadata: %w", err)
}
result["title"] = metadata.Title
result["artist"] = metadata.Artist
result["album"] = metadata.Album
result["album_artist"] = metadata.AlbumArtist
result["date"] = metadata.Date
result["track_number"] = metadata.TrackNumber
result["disc_number"] = metadata.DiscNumber
result["isrc"] = metadata.ISRC
result["lyrics"] = metadata.Lyrics
result["genre"] = metadata.Genre
result["label"] = metadata.Label
result["copyright"] = metadata.Copyright
result["composer"] = metadata.Composer
result["comment"] = metadata.Comment
quality, qualityErr := GetAudioQuality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
if quality.SampleRate > 0 && quality.TotalSamples > 0 {
result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate))
}
}
} else if isMp3 {
meta, err := ReadID3Tags(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetMP3Quality(filePath)
if qualityErr == nil {
result["bit_depth"] = quality.BitDepth
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else if isOgg {
meta, err := ReadOggVorbisComments(filePath)
if err == nil && meta != nil {
result["title"] = meta.Title
result["artist"] = meta.Artist
result["album"] = meta.Album
result["album_artist"] = meta.AlbumArtist
result["date"] = meta.Date
if meta.Date == "" {
result["date"] = meta.Year
}
result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC
result["genre"] = meta.Genre
result["composer"] = meta.Composer
result["comment"] = meta.Comment
}
quality, qualityErr := GetOggQuality(filePath)
if qualityErr == nil {
result["sample_rate"] = quality.SampleRate
result["duration"] = quality.Duration
}
} else {
return "", fmt.Errorf("unsupported file format: %s", filePath)
} }
jsonBytes, err := json.Marshal(result) jsonBytes, err := json.Marshal(result)
@@ -576,6 +667,66 @@ func ReadFileMetadata(filePath string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// EditFileMetadata writes metadata to an audio file.
// For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
func EditFileMetadata(filePath, metadataJSON string) (string, error) {
var fields map[string]string
if err := json.Unmarshal([]byte(metadataJSON), &fields); err != nil {
return "", fmt.Errorf("invalid metadata JSON: %w", err)
}
lower := strings.ToLower(filePath)
isFlac := strings.HasSuffix(lower, ".flac")
if isFlac {
trackNum := 0
discNum := 0
if v, ok := fields["track_number"]; ok && v != "" {
fmt.Sscanf(v, "%d", &trackNum)
}
if v, ok := fields["disc_number"]; ok && v != "" {
fmt.Sscanf(v, "%d", &discNum)
}
meta := Metadata{
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
Date: fields["date"],
TrackNumber: trackNum,
DiscNumber: discNum,
ISRC: fields["isrc"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
}
if err := EmbedMetadata(filePath, meta, ""); err != nil {
return "", fmt.Errorf("failed to write FLAC metadata: %w", err)
}
resp := map[string]any{
"success": true,
"method": "native",
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// MP3/Opus: return metadata for Dart-side FFmpeg embedding
resp := map[string]any{
"success": true,
"method": "ffmpeg",
"fields": fields,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
func SetDownloadDirectory(path string) error { func SetDownloadDirectory(path string) error {
return setDownloadDir(path) return setDownloadDir(path)
} }
@@ -1074,6 +1225,443 @@ func errorResponse(msg string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
// ==================== YOUTUBE PROVIDER (LOSSY ONLY) ====================
// DownloadFromYouTube downloads a track from YouTube via Cobalt API
// This is a lossy-only provider (Opus 256kbps or MP3 320kbps)
// It does NOT participate in the lossless fallback chain
func DownloadFromYouTube(requestJSON string) (string, error) {
var req DownloadRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return errorResponse("Invalid request: " + err.Error())
}
req.TrackName = strings.TrimSpace(req.TrackName)
req.ArtistName = strings.TrimSpace(req.ArtistName)
req.AlbumName = strings.TrimSpace(req.AlbumName)
req.AlbumArtist = strings.TrimSpace(req.AlbumArtist)
req.OutputDir = strings.TrimSpace(req.OutputDir)
req.OutputPath = strings.TrimSpace(req.OutputPath)
req.OutputExt = strings.TrimSpace(req.OutputExt)
if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" {
AddAllowedDownloadDir(req.OutputDir)
}
youtubeResult, err := downloadFromYouTube(req)
if err != nil {
return errorResponse(err.Error())
}
resp := DownloadResponse{
Success: true,
Message: "Downloaded from YouTube",
FilePath: youtubeResult.FilePath,
Service: "youtube",
Title: youtubeResult.Title,
Artist: youtubeResult.Artist,
Album: youtubeResult.Album,
ReleaseDate: youtubeResult.ReleaseDate,
TrackNumber: youtubeResult.TrackNumber,
DiscNumber: youtubeResult.DiscNumber,
ISRC: youtubeResult.ISRC,
LyricsLRC: youtubeResult.LyricsLRC,
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// IsYouTubeURLExport checks if a URL is a YouTube URL (exported for Flutter)
func IsYouTubeURLExport(urlStr string) bool {
return IsYouTubeURL(urlStr)
}
// ExtractYouTubeVideoIDExport extracts video ID from YouTube URL (exported for Flutter)
func ExtractYouTubeVideoIDExport(urlStr string) (string, error) {
return ExtractYouTubeVideoID(urlStr)
}
// ==================== COVER & LYRICS SAVE ====================
// DownloadCoverToFile downloads cover art from URL and saves to outputPath.
// If maxQuality is true, upgrades to highest available resolution.
func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
data, err := downloadCoverToMemory(coverURL, maxQuality)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
if err := os.WriteFile(outputPath, data, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Saved cover art to: %s (%d KB)\n", outputPath, len(data)/1024)
return nil
}
// ExtractCoverToFile extracts embedded cover art from audio file and saves to outputPath.
func ExtractCoverToFile(audioPath string, outputPath string) error {
lower := strings.ToLower(audioPath)
var coverData []byte
var err error
if strings.HasSuffix(lower, ".flac") {
coverData, err = ExtractCoverArt(audioPath)
} else if strings.HasSuffix(lower, ".mp3") {
coverData, _, err = extractMP3CoverArt(audioPath)
} else if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
coverData, _, err = extractOggCoverArt(audioPath)
} else {
return fmt.Errorf("unsupported audio format for cover extraction")
}
if err != nil {
return fmt.Errorf("failed to extract cover: %w", err)
}
if err := os.WriteFile(outputPath, coverData, 0644); err != nil {
return fmt.Errorf("failed to write cover file: %w", err)
}
GoLog("[Cover] Extracted cover art to: %s (%d KB)\n", outputPath, len(coverData)/1024)
return nil
}
// FetchAndSaveLyrics fetches lyrics from lrclib and saves as .lrc file.
func FetchAndSaveLyrics(trackName, artistName, spotifyID string, durationMs int64, outputPath string) error {
client := NewLyricsClient()
durationSec := float64(durationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec)
if err != nil {
return fmt.Errorf("lyrics not found: %w", err)
}
if lyrics.Instrumental {
return fmt.Errorf("track is instrumental, no lyrics available")
}
lrcContent := convertToLRCWithMetadata(lyrics, trackName, artistName)
if lrcContent == "" {
return fmt.Errorf("failed to generate LRC content")
}
if err := os.WriteFile(outputPath, []byte(lrcContent), 0644); err != nil {
return fmt.Errorf("failed to write LRC file: %w", err)
}
GoLog("[Lyrics] Saved LRC to: %s (%d lines)\n", outputPath, len(lyrics.Lines))
return nil
}
// ReEnrichFile re-embeds metadata, cover art, and lyrics into an existing audio file.
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) {
var req struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
}
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err)
}
if req.FilePath == "" {
return "", fmt.Errorf("file_path is required")
}
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
// When search_online is true, search for metadata from internet
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
found := false
// 1) Try Deezer first (reliable, no credentials needed)
GoLog("[ReEnrich] Trying Deezer search...\n")
deezerClient := GetDeezerClient()
{
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
deezerResults, err := deezerClient.SearchAll(ctx, searchQuery, 5, 0, "track")
cancel()
if err == nil && len(deezerResults.Tracks) > 0 {
track := deezerResults.Tracks[0]
GoLog("[ReEnrich] Deezer match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = "deezer:" + track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Deezer search failed: %v\n", err)
}
}
// 2) Try extension metadata providers (spotify-web etc) if Deezer failed
if !found {
GoLog("[ReEnrich] Trying extension metadata providers...\n")
manager := GetExtensionManager()
extTracks, extErr := manager.SearchTracksWithExtensions(searchQuery, 5)
if extErr == nil && len(extTracks) > 0 {
track := extTracks[0]
GoLog("[ReEnrich] Extension match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else {
req.SpotifyID = track.ID
}
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
coverURL := track.ResolvedCoverURL()
if coverURL != "" {
req.CoverURL = coverURL
}
req.DurationMs = int64(track.DurationMS)
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
found = true
} else if extErr != nil {
GoLog("[ReEnrich] Extension search failed: %v\n", extErr)
}
}
// 3) Try Spotify built-in API as last resort (will be deprecated)
if !found {
GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
spotifyClient, spotifyErr := NewSpotifyMetadataClient()
if spotifyErr == nil {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
cancel()
if err == nil && len(results.Tracks) > 0 {
track := results.Tracks[0]
GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Spotify search failed: %v\n", err)
}
} else {
GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
}
}
// Try to get extended metadata (genre, label) from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
extMeta, err := deezerClient.GetExtendedMetadataByISRC(ctx, req.ISRC)
cancel()
if err == nil && extMeta != nil {
if req.Genre == "" && extMeta.Genre != "" {
req.Genre = extMeta.Genre
}
if req.Label == "" && extMeta.Label != "" {
req.Label = extMeta.Label
}
GoLog("[ReEnrich] Extended metadata: genre=%s, label=%s\n", req.Genre, req.Label)
}
}
if !found {
GoLog("[ReEnrich] No online match found, using existing metadata\n")
}
}
// Log metadata summary before embedding
GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n",
req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist)
GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n",
req.TrackNumber, req.DiscNumber, req.ReleaseDate, req.ISRC, req.Genre, req.Label)
// Download cover art to temp file
var coverTempPath string
if req.CoverURL != "" {
coverData, err := downloadCoverToMemory(req.CoverURL, req.MaxQuality)
if err != nil {
GoLog("[ReEnrich] Failed to download cover: %v\n", err)
} else {
tmpFile, err := os.CreateTemp("", "reenrich_cover_*.jpg")
if err == nil {
coverTempPath = tmpFile.Name()
tmpFile.Write(coverData)
tmpFile.Close()
GoLog("[ReEnrich] Cover downloaded: %d KB\n", len(coverData)/1024)
}
}
}
// Only cleanup cover temp for FLAC (native embed).
// For MP3/Opus, Dart needs the file for FFmpeg — Dart handles cleanup.
cleanupCover := true
defer func() {
if cleanupCover && coverTempPath != "" {
os.Remove(coverTempPath)
}
}()
// Fetch lyrics
var lyricsLRC string
if req.EmbedLyrics {
client := NewLyricsClient()
durationSec := float64(req.DurationMs) / 1000.0
lyrics, err := client.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, durationSec)
if err != nil {
GoLog("[ReEnrich] Lyrics not found: %v\n", err)
} else if !lyrics.Instrumental {
lyricsLRC = convertToLRCWithMetadata(lyrics, req.TrackName, req.ArtistName)
GoLog("[ReEnrich] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
GoLog("[ReEnrich] Track is instrumental\n")
}
}
lower := strings.ToLower(req.FilePath)
isFlac := strings.HasSuffix(lower, ".flac")
// Build enriched metadata response for Dart (includes online search results)
enrichedMeta := map[string]interface{}{
"track_name": req.TrackName,
"artist_name": req.ArtistName,
"album_name": req.AlbumName,
"album_artist": req.AlbumArtist,
"release_date": req.ReleaseDate,
"track_number": req.TrackNumber,
"disc_number": req.DiscNumber,
"isrc": req.ISRC,
"genre": req.Genre,
"label": req.Label,
"copyright": req.Copyright,
"cover_url": req.CoverURL,
"spotify_id": req.SpotifyID,
"duration_ms": req.DurationMs,
}
if isFlac {
// Native Go FLAC metadata embedding
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Lyrics: lyricsLRC,
}
if err := EmbedMetadata(req.FilePath, metadata, coverTempPath); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
GoLog("[ReEnrich] FLAC metadata embedded successfully\n")
result := map[string]interface{}{
"method": "native",
"success": true,
"enriched_metadata": enrichedMeta,
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// MP3/Opus: return metadata map for Dart to use FFmpeg
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
result := map[string]interface{}{
"method": "ffmpeg",
"cover_path": coverTempPath,
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": map[string]string{
"TITLE": req.TrackName,
"ARTIST": req.ArtistName,
"ALBUM": req.AlbumName,
"ALBUMARTIST": req.AlbumArtist,
"DATE": req.ReleaseDate,
"ISRC": req.ISRC,
"GENRE": req.Genre,
},
}
if req.TrackNumber > 0 {
result["metadata"].(map[string]string)["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
}
if req.DiscNumber > 0 {
result["metadata"].(map[string]string)["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
}
if req.Label != "" {
result["metadata"].(map[string]string)["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
result["metadata"].(map[string]string)["COPYRIGHT"] = req.Copyright
}
if lyricsLRC != "" {
result["metadata"].(map[string]string)["LYRICS"] = lyricsLRC
result["metadata"].(map[string]string)["UNSYNCEDLYRICS"] = lyricsLRC
}
jsonBytes, _ := json.Marshal(result)
return string(jsonBytes), nil
}
// ==================== EXTENSION SYSTEM ==================== // ==================== EXTENSION SYSTEM ====================
func InitExtensionSystem(extensionsDir, dataDir string) error { func InitExtensionSystem(extensionsDir, dataDir string) error {
+24
View File
@@ -29,6 +29,8 @@ type Metadata struct {
Genre string Genre string
Label string Label string
Copyright string Copyright string
Composer string
Comment string
} }
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error { func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
@@ -98,6 +100,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
setComment(cmt, "COPYRIGHT", metadata.Copyright) setComment(cmt, "COPYRIGHT", metadata.Copyright)
} }
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -206,6 +216,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
setComment(cmt, "COPYRIGHT", metadata.Copyright) setComment(cmt, "COPYRIGHT", metadata.Copyright)
} }
if metadata.Composer != "" {
setComment(cmt, "COMPOSER", metadata.Composer)
}
if metadata.Comment != "" {
setComment(cmt, "COMMENT", metadata.Comment)
}
cmtBlock := cmt.Marshal() cmtBlock := cmt.Marshal()
if cmtIdx >= 0 { if cmtIdx >= 0 {
f.Meta[cmtIdx] = &cmtBlock f.Meta[cmtIdx] = &cmtBlock
@@ -292,6 +310,12 @@ func ReadMetadata(filePath string) (*Metadata, error) {
metadata.Date = getComment(cmt, "YEAR") metadata.Date = getComment(cmt, "YEAR")
} }
metadata.Genre = getComment(cmt, "GENRE")
metadata.Label = getComment(cmt, "ORGANIZATION")
metadata.Copyright = getComment(cmt, "COPYRIGHT")
metadata.Composer = getComment(cmt, "COMPOSER")
metadata.Comment = getComment(cmt, "COMMENT")
break break
} }
} }
+143 -12
View File
@@ -15,18 +15,21 @@ type SongLinkClient struct {
} }
type TrackAvailability struct { type TrackAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"` Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"` Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"` Qobuz bool `json:"qobuz"`
Deezer bool `json:"deezer"` Deezer bool `json:"deezer"`
TidalURL string `json:"tidal_url,omitempty"` YouTube bool `json:"youtube"`
AmazonURL string `json:"amazon_url,omitempty"` TidalURL string `json:"tidal_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"` AmazonURL string `json:"amazon_url,omitempty"`
DeezerURL string `json:"deezer_url,omitempty"` QobuzURL string `json:"qobuz_url,omitempty"`
DeezerID string `json:"deezer_id,omitempty"` DeezerURL string `json:"deezer_url,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"` YouTubeURL string `json:"youtube_url,omitempty"`
TidalID string `json:"tidal_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"`
QobuzID string `json:"qobuz_id,omitempty"`
TidalID string `json:"tidal_id,omitempty"`
YouTubeID string `json:"youtube_id,omitempty"`
} }
var ( var (
@@ -119,6 +122,22 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri
availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL)
} }
// Prefer youtubeMusic URLs — they bypass Cobalt login requirements
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
// Fallback to regular youtube if youtubeMusic not available
if !availability.YouTube {
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -246,6 +265,52 @@ func extractTidalIDFromURL(tidalURL string) string {
return "" return ""
} }
// extractYouTubeIDFromURL extracts YouTube video ID from URL
// URL formats:
// - https://www.youtube.com/watch?v=VIDEO_ID
// - https://youtu.be/VIDEO_ID
// - https://music.youtube.com/watch?v=VIDEO_ID
func extractYouTubeIDFromURL(youtubeURL string) string {
if youtubeURL == "" {
return ""
}
// Handle youtu.be short URLs
if strings.Contains(youtubeURL, "youtu.be/") {
parts := strings.Split(youtubeURL, "youtu.be/")
if len(parts) >= 2 {
idPart := parts[1]
if idx := strings.Index(idPart, "?"); idx > 0 {
idPart = idPart[:idx]
}
if idx := strings.Index(idPart, "&"); idx > 0 {
idPart = idPart[:idx]
}
return strings.TrimSpace(idPart)
}
}
// Handle youtube.com URLs with ?v= parameter
parsed, err := url.Parse(youtubeURL)
if err != nil {
return ""
}
if v := parsed.Query().Get("v"); v != "" {
return v
}
// Handle /embed/ format
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0]
}
}
return ""
}
// isNumeric is defined in library_scan.go // isNumeric is defined in library_scan.go
func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) {
@@ -261,6 +326,20 @@ func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string,
return availability.DeezerID, nil return availability.DeezerID, nil
} }
// GetYouTubeURLFromSpotify converts a Spotify track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromSpotify(spotifyTrackID string) (string, error) {
availability, err := s.CheckTrackAvailability(spotifyTrackID, "")
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
// AlbumAvailability represents album availability on different platforms // AlbumAvailability represents album availability on different platforms
type AlbumAvailability struct { type AlbumAvailability struct {
SpotifyID string `json:"spotify_id"` SpotifyID string `json:"spotify_id"`
@@ -441,6 +520,19 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -528,6 +620,19 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
@@ -584,6 +689,20 @@ func (s *SongLinkClient) GetAmazonURLFromDeezer(deezerTrackID string) (string, e
return availability.AmazonURL, nil return availability.AmazonURL, nil
} }
// GetYouTubeURLFromDeezer converts a Deezer track ID to YouTube URL using SongLink
func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, error) {
availability, err := s.CheckAvailabilityFromDeezer(deezerTrackID)
if err != nil {
return "", err
}
if !availability.YouTube || availability.YouTubeURL == "" {
return "", fmt.Errorf("track not found on YouTube")
}
return availability.YouTubeURL, nil
}
func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) {
songLinkRateLimiter.WaitForSlot() songLinkRateLimiter.WaitForSlot()
@@ -652,6 +771,18 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila
availability.DeezerURL = deezerLink.URL availability.DeezerURL = deezerLink.URL
availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL)
} }
if youtubeLink, ok := songLinkResp.LinksByPlatform["youtube"]; ok && youtubeLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = youtubeLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(youtubeLink.URL)
}
if !availability.YouTube {
if ytMusicLink, ok := songLinkResp.LinksByPlatform["youtubeMusic"]; ok && ytMusicLink.URL != "" {
availability.YouTube = true
availability.YouTubeURL = ytMusicLink.URL
availability.YouTubeID = extractYouTubeIDFromURL(ytMusicLink.URL)
}
}
return availability, nil return availability, nil
} }
+566
View File
@@ -0,0 +1,566 @@
// Package gobackend - YouTube download via Cobalt API (lossy-only provider)
package gobackend
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
type YouTubeDownloader struct {
client *http.Client
apiURL string
mu sync.Mutex
}
var (
globalYouTubeDownloader *YouTubeDownloader
youtubeDownloaderOnce sync.Once
)
type YouTubeQuality string
const (
YouTubeQualityOpus256 YouTubeQuality = "opus_256"
YouTubeQualityMP3320 YouTubeQuality = "mp3_320"
)
type CobaltRequest struct {
URL string `json:"url"`
AudioBitrate string `json:"audioBitrate,omitempty"`
AudioFormat string `json:"audioFormat,omitempty"`
DownloadMode string `json:"downloadMode,omitempty"`
FilenameStyle string `json:"filenameStyle,omitempty"`
DisableMetadata bool `json:"disableMetadata,omitempty"`
}
type CobaltResponse struct {
Status string `json:"status"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
Error *struct {
Code string `json:"code"`
Context *struct {
Service string `json:"service,omitempty"`
Limit int `json:"limit,omitempty"`
} `json:"context,omitempty"`
} `json:"error,omitempty"`
}
type YouTubeDownloadResult struct {
FilePath string
Title string
Artist string
Album string
ReleaseDate string
TrackNumber int
DiscNumber int
ISRC string
Format string // "opus" or "mp3"
Bitrate int
LyricsLRC string
CoverData []byte
}
func NewYouTubeDownloader() *YouTubeDownloader {
youtubeDownloaderOnce.Do(func() {
globalYouTubeDownloader = &YouTubeDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second),
apiURL: "https://api.qwkuns.me",
}
})
return globalYouTubeDownloader
}
// SearchYouTube returns a YouTube Music search URL for the given track
func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
searchQuery := url.QueryEscape(query)
GoLog("[YouTube] Search query: %s\n", query)
youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery)
return youtubeMusicURL, nil
}
func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) {
y.mu.Lock()
defer y.mu.Unlock()
var audioFormat string
var audioBitrate string
switch quality {
case YouTubeQualityOpus256:
audioFormat = "opus"
audioBitrate = "256"
case YouTubeQualityMP3320:
audioFormat = "mp3"
audioBitrate = "320"
default:
audioFormat = "mp3"
audioBitrate = "320"
}
// Try SpotubeDL first (primary)
videoID, extractErr := ExtractYouTubeVideoID(youtubeURL)
if extractErr == nil {
GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n",
videoID, audioFormat, audioBitrate)
resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate)
if err == nil {
return resp, nil
}
GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err)
} else {
GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr)
}
// Fallback: direct Cobalt API (api.qwkuns.me)
cobaltURL := toYouTubeMusicURL(youtubeURL)
GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n",
cobaltURL, audioFormat, audioBitrate)
resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate)
if err != nil {
return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err)
}
return resp, nil
}
// requestCobaltDirect sends a download request to the primary Cobalt API.
func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) {
reqBody := CobaltRequest{
URL: videoURL,
AudioFormat: audioFormat,
AudioBitrate: audioBitrate,
DownloadMode: "audio",
FilenameStyle: "basic",
DisableMetadata: true,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData)))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("cobalt API request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body))
}
var cobaltResp CobaltResponse
if err := json.Unmarshal(body, &cobaltResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if cobaltResp.Status == "error" && cobaltResp.Error != nil {
return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code)
}
if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" {
return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status)
}
if cobaltResp.URL == "" {
return nil, fmt.Errorf("no download URL in response")
}
GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status)
return &cobaltResp, nil
}
// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances).
func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) {
apiURL := fmt.Sprintf("https://spotubedl.com/api/download/%s?engine=v1&format=%s&quality=%s",
videoID, audioFormat, audioBitrate)
GoLog("[YouTube] Requesting from SpotubeDL: %s\n", apiURL)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
return nil, fmt.Errorf("spotubedl request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
GoLog("[YouTube] SpotubeDL response status: %d\n", resp.StatusCode)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("spotubedl returned status %d: %s", resp.StatusCode, string(body))
}
var result struct {
URL string `json:"url"`
}
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse spotubedl response: %w", err)
}
if result.URL == "" {
return nil, fmt.Errorf("no download URL from spotubedl")
}
GoLog("[YouTube] Got download URL from SpotubeDL\n")
return &CobaltResponse{
Status: "tunnel",
URL: result.URL,
}, nil
}
func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
ctx := context.Background()
if itemID != "" {
StartItemProgress(itemID)
defer CompleteItemProgress(itemID)
ctx = initDownloadCancel(itemID)
defer clearDownloadCancel(itemID)
}
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := DoRequestWithUserAgent(y.client, req)
if err != nil {
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
expectedSize := resp.ContentLength
if expectedSize > 0 && itemID != "" {
SetItemBytesTotal(itemID, expectedSize)
}
out, err := openOutputForWrite(outputPath, outputFD)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
bufWriter := bufio.NewWriterSize(out, 256*1024)
var written int64
if itemID != "" {
progressWriter := NewItemProgressWriter(bufWriter, itemID)
written, err = io.Copy(progressWriter, resp.Body)
} else {
written, err = io.Copy(bufWriter, resp.Body)
}
flushErr := bufWriter.Flush()
closeErr := out.Close()
if err != nil {
cleanupOutputOnError(outputPath, outputFD)
if isDownloadCancelled(itemID) {
return ErrDownloadCancelled
}
return fmt.Errorf("download interrupted: %w", err)
}
if flushErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to flush buffer: %w", flushErr)
}
if closeErr != nil {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("failed to close file: %w", closeErr)
}
if expectedSize > 0 && written != expectedSize {
cleanupOutputOnError(outputPath, outputFD)
return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written)
}
GoLog("[YouTube] Download completed: %d bytes written\n", written)
return nil
}
func BuildYouTubeSearchURL(trackName, artistName string) string {
query := fmt.Sprintf("%s %s official audio", artistName, trackName)
return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query))
}
func BuildYouTubeWatchURL(videoID string) string {
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
// isYouTubeVideoID checks if s is an 11-char YouTube video ID
func isYouTubeVideoID(s string) bool {
if len(s) != 11 {
return false
}
for _, c := range s {
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}
func IsYouTubeURL(urlStr string) bool {
lower := strings.ToLower(urlStr)
return strings.Contains(lower, "youtube.com") ||
strings.Contains(lower, "youtu.be") ||
strings.Contains(lower, "music.youtube.com")
}
// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format.
// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt.
func toYouTubeMusicURL(rawURL string) string {
videoID, err := ExtractYouTubeVideoID(rawURL)
if err != nil {
return rawURL
}
return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID)
}
func ExtractYouTubeVideoID(urlStr string) (string, error) {
if strings.Contains(urlStr, "youtu.be/") {
parts := strings.Split(urlStr, "youtu.be/")
if len(parts) >= 2 {
videoID := strings.Split(parts[1], "?")[0]
videoID = strings.Split(videoID, "&")[0]
return strings.TrimSpace(videoID), nil
}
}
parsed, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
// /watch?v=
if v := parsed.Query().Get("v"); v != "" {
return v, nil
}
// /embed/
if strings.Contains(parsed.Path, "/embed/") {
parts := strings.Split(parsed.Path, "/embed/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
// /v/
if strings.Contains(parsed.Path, "/v/") {
parts := strings.Split(parsed.Path, "/v/")
if len(parts) >= 2 {
return strings.Split(parts[1], "/")[0], nil
}
}
return "", fmt.Errorf("could not extract video ID from URL")
}
func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) {
downloader := NewYouTubeDownloader()
var quality YouTubeQuality
switch strings.ToLower(req.Quality) {
case "opus_256", "opus256", "opus":
quality = YouTubeQualityOpus256
case "mp3_320", "mp3320", "mp3":
quality = YouTubeQualityMP3320
default:
quality = YouTubeQualityMP3320 // Default to MP3 320kbps
}
// URL lookup priority: YouTube video ID > Spotify ID > Deezer ID > ISRC
var youtubeURL string
var lookupErr error
// SpotifyID might actually be a YouTube video ID (from YT Music extension)
if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) {
youtubeURL = BuildYouTubeWatchURL(req.SpotifyID)
GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL)
}
// Try Spotify ID via SongLink
if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL)
}
}
// Try Deezer ID via SongLink
if youtubeURL == "" && req.DeezerID != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID)
songlink := NewSongLinkClient()
youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID)
if lookupErr != nil {
GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr)
} else {
GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL)
}
}
// Try ISRC via SongLink
if youtubeURL == "" && req.ISRC != "" {
GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC)
songlink := NewSongLinkClient()
availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC)
if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" {
youtubeURL = availability.YouTubeURL
GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL)
} else if isrcErr != nil {
GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr)
}
}
// Cobalt requires direct video URLs, not search URLs
if youtubeURL == "" {
return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName)
}
GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL)
cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality)
if err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
var ext string
var format string
var bitrate int
switch quality {
case YouTubeQualityOpus256:
ext = ".opus"
format = "opus"
bitrate = 256
case YouTubeQualityMP3320:
ext = ".mp3"
format = "mp3"
bitrate = 320
}
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{
"title": req.TrackName,
"artist": req.ArtistName,
"album": req.AlbumName,
"track": req.TrackNumber,
"year": extractYear(req.ReleaseDate),
"disc": req.DiscNumber,
})
filename = sanitizeFilename(filename) + ext
var outputPath string
isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != ""
if isSafOutput {
outputPath = strings.TrimSpace(req.OutputPath)
if outputPath == "" && isFDOutput(req.OutputFD) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
}
} else {
outputPath = req.OutputDir + "/" + filename
}
GoLog("[YouTube] Downloading to: %s\n", outputPath)
// Parallel fetch cover art + lyrics
var parallelResult *ParallelDownloadResult
if req.EmbedLyrics || req.CoverURL != "" {
GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n")
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
int64(req.DurationMS),
)
}
if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil {
return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
lyricsLRC := ""
var coverData []byte
if parallelResult != nil {
if parallelResult.LyricsLRC != "" {
lyricsLRC = parallelResult.LyricsLRC
GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines))
}
if parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData))
}
}
return YouTubeDownloadResult{
FilePath: outputPath,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
ReleaseDate: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Format: format,
Bitrate: bitrate,
LyricsLRC: lyricsLRC,
CoverData: coverData,
}, nil
}
+8
View File
@@ -217,6 +217,14 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "editFileMetadata":
let args = call.arguments as! [String: Any]
let filePath = args["file_path"] as! String
let metadataJson = args["metadata_json"] as? String ?? "{}"
let response = GobackendEditFileMetadata(filePath, metadataJson, &error)
if let error = error { throw error }
return response
case "searchDeezerAll": case "searchDeezerAll":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let query = args["query"] as! String let query = args["query"] as! String
+2 -2
View File
@@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.5.2'; static const String version = '3.6.0';
static const String buildNumber = '76'; static const String buildNumber = '77';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
+348
View File
@@ -712,6 +712,12 @@ abstract class AppLocalizations {
/// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'** /// **'Spotify requires your own API credentials. Get them free from developer.spotify.com'**
String get optionsSpotifyWarning; 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 /// Extensions page title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3496,6 +3502,12 @@ abstract class AppLocalizations {
/// **'Actual quality depends on track availability from the service'** /// **'Actual quality depends on track availability from the service'**
String get qualityNote; String get qualityNote;
/// Note for YouTube service explaining lossy-only quality
///
/// In en, this message translates to:
/// **'YouTube provides lossy audio only. Not part of lossless fallback.'**
String get youtubeQualityNote;
/// Setting - show quality picker /// Setting - show quality picker
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3520,6 +3532,24 @@ abstract class AppLocalizations {
/// **'Album Folder Structure'** /// **'Album Folder Structure'**
String get downloadAlbumFolderStructure; 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 /// Setting - output file format
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3934,6 +3964,18 @@ abstract class AppLocalizations {
/// **'Playlist'** /// **'Playlist'**
String get recentTypePlaylist; 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 /// Snackbar message when tapping playlist in recent access
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4102,6 +4144,18 @@ abstract class AppLocalizations {
/// **'Scan music & detect duplicates'** /// **'Scan music & detect duplicates'**
String get settingsLocalLibrarySubtitle; 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 /// Library settings page title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -4785,6 +4839,300 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'No orphaned entries found'** /// **'No orphaned entries found'**
String get cleanupOrphanedDownloadsNone; 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;
/// Menu action - save album cover art as file
///
/// In en, this message translates to:
/// **'Save Cover Art'**
String get trackSaveCoverArt;
/// Subtitle for save cover art action
///
/// In en, this message translates to:
/// **'Save album art as .jpg file'**
String get trackSaveCoverArtSubtitle;
/// Menu action - save lyrics as .lrc file
///
/// In en, this message translates to:
/// **'Save Lyrics (.lrc)'**
String get trackSaveLyrics;
/// Subtitle for save lyrics action
///
/// In en, this message translates to:
/// **'Fetch and save lyrics as .lrc file'**
String get trackSaveLyricsSubtitle;
/// Menu action - re-embed metadata into audio file
///
/// In en, this message translates to:
/// **'Re-enrich Metadata'**
String get trackReEnrich;
/// Subtitle for re-enrich metadata action
///
/// In en, this message translates to:
/// **'Re-embed metadata without re-downloading'**
String get trackReEnrichSubtitle;
/// Subtitle for re-enrich metadata action for local items
///
/// In en, this message translates to:
/// **'Search metadata online and embed into file'**
String get trackReEnrichOnlineSubtitle;
/// Menu action - edit embedded metadata
///
/// In en, this message translates to:
/// **'Edit Metadata'**
String get trackEditMetadata;
/// Snackbar after cover art saved
///
/// In en, this message translates to:
/// **'Cover art saved to {fileName}'**
String trackCoverSaved(String fileName);
/// Snackbar when no cover art URL or embedded cover
///
/// In en, this message translates to:
/// **'No cover art source available'**
String get trackCoverNoSource;
/// Snackbar after lyrics saved
///
/// In en, this message translates to:
/// **'Lyrics saved to {fileName}'**
String trackLyricsSaved(String fileName);
/// Snackbar while re-enriching metadata
///
/// In en, this message translates to:
/// **'Re-enriching metadata...'**
String get trackReEnrichProgress;
/// Snackbar while searching metadata from internet for local items
///
/// In en, this message translates to:
/// **'Searching metadata online...'**
String get trackReEnrichSearching;
/// Snackbar after successful re-enrichment
///
/// In en, this message translates to:
/// **'Metadata re-enriched successfully'**
String get trackReEnrichSuccess;
/// Snackbar when FFmpeg embed fails for MP3/Opus
///
/// In en, this message translates to:
/// **'FFmpeg metadata embed failed'**
String get trackReEnrichFfmpegFailed;
/// Snackbar when save operation fails
///
/// In en, this message translates to:
/// **'Failed: {error}'**
String trackSaveFailed(String error);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+210
View File
@@ -352,6 +352,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify erfordert eigene API-Anmeldedaten. Kostenlos erhältlich auf developer.spotify.com'; '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 @override
String get extensionsTitle => 'Erweiterungen'; String get extensionsTitle => 'Erweiterungen';
@@ -1929,6 +1933,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1941,6 +1949,17 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2176,6 +2195,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2282,6 +2307,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2694,4 +2725,183 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+210
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; '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 @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -1914,6 +1918,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1926,6 +1934,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2161,6 +2180,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2267,6 +2292,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2679,4 +2710,183 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+210
View File
@@ -343,6 +343,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; '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 @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -1914,6 +1918,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1926,6 +1934,17 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2161,6 +2180,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2267,6 +2292,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2679,6 +2710,185 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+210
View File
@@ -343,6 +343,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; '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 @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -1914,6 +1918,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1926,6 +1934,17 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2161,6 +2180,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2267,6 +2292,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2679,4 +2710,183 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+210
View File
@@ -343,6 +343,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; '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 @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -1914,6 +1918,10 @@ class AppLocalizationsHi extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1926,6 +1934,17 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2161,6 +2180,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2267,6 +2292,12 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2679,4 +2710,183 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+214
View File
@@ -347,6 +347,10 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify memerlukan kredensial API Anda sendiri. Dapatkan gratis dari developer.spotify.com'; '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 @override
String get extensionsTitle => 'Ekstensi'; String get extensionsTitle => 'Ekstensi';
@@ -1926,6 +1930,10 @@ class AppLocalizationsId extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh';
@@ -1938,6 +1946,18 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Struktur Folder Album'; 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 @override
String get downloadSaveFormat => 'Simpan Format'; String get downloadSaveFormat => 'Simpan Format';
@@ -2174,6 +2194,12 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'Belum ada item terbaru';
@override
String get recentShowAllDownloads => 'Tampilkan Semua Download';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2280,6 +2306,12 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Penyimpanan & Cache';
@override
String get settingsCacheSubtitle => 'Lihat ukuran dan bersihkan data cache';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2694,4 +2726,186 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => String get cleanupOrphanedDownloadsNone =>
'Tidak ada entri unduhan tidak valid'; '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';
@override
String get trackSaveCoverArt => 'Simpan Cover Art';
@override
String get trackSaveCoverArtSubtitle =>
'Simpan cover album sebagai file .jpg';
@override
String get trackSaveLyrics => 'Simpan Lirik (.lrc)';
@override
String get trackSaveLyricsSubtitle =>
'Ambil dan simpan lirik sebagai file .lrc';
@override
String get trackReEnrich => 'Perkaya Ulang Metadata';
@override
String get trackReEnrichSubtitle =>
'Tanamkan ulang metadata tanpa mengunduh ulang';
@override
String get trackReEnrichOnlineSubtitle =>
'Cari metadata dari internet dan tanamkan ke file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art disimpan ke $fileName';
}
@override
String get trackCoverNoSource => 'Tidak ada sumber cover art';
@override
String trackLyricsSaved(String fileName) {
return 'Lirik disimpan ke $fileName';
}
@override
String get trackReEnrichProgress => 'Memperkaya ulang metadata...';
@override
String get trackReEnrichSearching => 'Mencari metadata dari internet...';
@override
String get trackReEnrichSuccess => 'Metadata berhasil diperkaya ulang';
@override
String get trackReEnrichFfmpegFailed =>
'Gagal menanamkan metadata via FFmpeg';
@override
String trackSaveFailed(String error) {
return 'Gagal: $error';
}
} }
+210
View File
@@ -340,6 +340,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify は独自の API 認証情報が必要です。developer.spotify.com から取得できます。'; '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 @override
String get extensionsTitle => '拡張'; String get extensionsTitle => '拡張';
@@ -1902,6 +1906,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; String get downloadAskBeforeDownload => 'ダウンロード前に確認する';
@@ -1914,6 +1922,17 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'アルバムフォルダの構造'; 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 @override
String get downloadSaveFormat => '形式を保存'; String get downloadSaveFormat => '形式を保存';
@@ -2147,6 +2166,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'プレイリスト'; String get recentTypePlaylist => 'プレイリスト';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'プレイリスト: $name'; return 'プレイリスト: $name';
@@ -2253,6 +2278,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2665,4 +2696,183 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+210
View File
@@ -343,6 +343,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; '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 @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -1914,6 +1918,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1926,6 +1934,17 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2161,6 +2180,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2267,6 +2292,12 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2679,4 +2710,183 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+210
View File
@@ -343,6 +343,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; '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 @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -1914,6 +1918,10 @@ class AppLocalizationsNl extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1926,6 +1934,17 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2161,6 +2180,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2267,6 +2292,12 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2679,4 +2710,183 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+210
View File
@@ -343,6 +343,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; '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 @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -1914,6 +1918,10 @@ class AppLocalizationsPt extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1926,6 +1934,17 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2161,6 +2180,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2267,6 +2292,12 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2679,6 +2710,185 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).
+210
View File
@@ -354,6 +354,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify требует ваши собственные учетные данные API. Получите их бесплатно на сайте developer.spotify.com'; '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 @override
String get extensionsTitle => 'Расширения'; String get extensionsTitle => 'Расширения';
@@ -1952,6 +1956,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Фактическое качество зависит от доступности треков в сервисе'; 'Фактическое качество зависит от доступности треков в сервисе';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием';
@@ -1964,6 +1972,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Структура папок альбома'; 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 @override
String get downloadSaveFormat => 'Формат сохранения'; String get downloadSaveFormat => 'Формат сохранения';
@@ -2206,6 +2225,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Плейлист'; String get recentTypePlaylist => 'Плейлист';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Плейлист: $name'; return 'Плейлист: $name';
@@ -2313,6 +2338,12 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2725,4 +2756,183 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+210
View File
@@ -348,6 +348,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify\'ın senin API kimlik bilgilerine ihtiyacı var. Onları developer.spotify.com\'dan alabilirsin'; '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 @override
String get extensionsTitle => 'Eklentiler'; String get extensionsTitle => 'Eklentiler';
@@ -1929,6 +1933,10 @@ class AppLocalizationsTr extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1941,6 +1949,17 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2176,6 +2195,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2282,6 +2307,12 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2694,4 +2725,183 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
+210
View File
@@ -343,6 +343,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsSpotifyWarning => String get optionsSpotifyWarning =>
'Spotify requires your own API credentials. Get them free from developer.spotify.com'; '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 @override
String get extensionsTitle => 'Extensions'; String get extensionsTitle => 'Extensions';
@@ -1914,6 +1918,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get qualityNote => String get qualityNote =>
'Actual quality depends on track availability from the service'; 'Actual quality depends on track availability from the service';
@override
String get youtubeQualityNote =>
'YouTube provides lossy audio only. Not part of lossless fallback.';
@override @override
String get downloadAskBeforeDownload => 'Ask Before Download'; String get downloadAskBeforeDownload => 'Ask Before Download';
@@ -1926,6 +1934,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get downloadAlbumFolderStructure => 'Album Folder Structure'; 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 @override
String get downloadSaveFormat => 'Save Format'; String get downloadSaveFormat => 'Save Format';
@@ -2161,6 +2180,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get recentTypePlaylist => 'Playlist'; String get recentTypePlaylist => 'Playlist';
@override
String get recentEmpty => 'No recent items yet';
@override
String get recentShowAllDownloads => 'Show All Downloads';
@override @override
String recentPlaylistInfo(String name) { String recentPlaylistInfo(String name) {
return 'Playlist: $name'; return 'Playlist: $name';
@@ -2267,6 +2292,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates'; String get settingsLocalLibrarySubtitle => 'Scan music & detect duplicates';
@override
String get settingsCache => 'Storage & Cache';
@override
String get settingsCacheSubtitle => 'View size and clear cached data';
@override @override
String get libraryTitle => 'Local Library'; String get libraryTitle => 'Local Library';
@@ -2679,6 +2710,185 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get cleanupOrphanedDownloadsNone => 'No orphaned entries found'; 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';
@override
String get trackSaveCoverArt => 'Save Cover Art';
@override
String get trackSaveCoverArtSubtitle => 'Save album art as .jpg file';
@override
String get trackSaveLyrics => 'Save Lyrics (.lrc)';
@override
String get trackSaveLyricsSubtitle => 'Fetch and save lyrics as .lrc file';
@override
String get trackReEnrich => 'Re-enrich Metadata';
@override
String get trackReEnrichSubtitle =>
'Re-embed metadata without re-downloading';
@override
String get trackReEnrichOnlineSubtitle =>
'Search metadata online and embed into file';
@override
String get trackEditMetadata => 'Edit Metadata';
@override
String trackCoverSaved(String fileName) {
return 'Cover art saved to $fileName';
}
@override
String get trackCoverNoSource => 'No cover art source available';
@override
String trackLyricsSaved(String fileName) {
return 'Lyrics saved to $fileName';
}
@override
String get trackReEnrichProgress => 'Re-enriching metadata...';
@override
String get trackReEnrichSearching => 'Searching metadata online...';
@override
String get trackReEnrichSuccess => 'Metadata re-enriched successfully';
@override
String get trackReEnrichFfmpegFailed => 'FFmpeg metadata embed failed';
@override
String trackSaveFailed(String error) {
return 'Failed: $error';
}
} }
/// The translations for Chinese, as used in China (`zh_CN`). /// The translations for Chinese, as used in China (`zh_CN`).
+173 -1
View File
@@ -241,6 +241,8 @@
"@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"}, "@optionsSpotifyCredentialsRequired": {"description": "Prompt to set up credentials"},
"optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com", "optionsSpotifyWarning": "Spotify requires your own API credentials. Get them free from developer.spotify.com",
"@optionsSpotifyWarning": {"description": "Info about Spotify API requirement"}, "@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": "Extensions",
"@extensionsTitle": {"description": "Extensions page title"}, "@extensionsTitle": {"description": "Extensions page title"},
@@ -1412,6 +1414,8 @@
"@lossyFormatOpusSubtitle": {"description": "Opus format description"}, "@lossyFormatOpusSubtitle": {"description": "Opus format description"},
"qualityNote": "Actual quality depends on track availability from the service", "qualityNote": "Actual quality depends on track availability from the service",
"@qualityNote": {"description": "Note about quality availability"}, "@qualityNote": {"description": "Note about quality availability"},
"youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.",
"@youtubeQualityNote": {"description": "Note for YouTube service explaining lossy-only quality"},
"downloadAskBeforeDownload": "Ask Before Download", "downloadAskBeforeDownload": "Ask Before Download",
"@downloadAskBeforeDownload": {"description": "Setting - show quality picker"}, "@downloadAskBeforeDownload": {"description": "Setting - show quality picker"},
@@ -1421,6 +1425,12 @@
"@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"}, "@downloadSeparateSinglesFolder": {"description": "Setting - separate folder for singles"},
"downloadAlbumFolderStructure": "Album Folder Structure", "downloadAlbumFolderStructure": "Album Folder Structure",
"@downloadAlbumFolderStructure": {"description": "Setting - album folder organization"}, "@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": "Save Format",
"@downloadSaveFormat": {"description": "Setting - output file format"}, "@downloadSaveFormat": {"description": "Setting - output file format"},
"downloadSelectService": "Select Service", "downloadSelectService": "Select Service",
@@ -1594,6 +1604,12 @@
"@recentTypeSong": {"description": "Recent access item type - song/track"}, "@recentTypeSong": {"description": "Recent access item type - song/track"},
"recentTypePlaylist": "Playlist", "recentTypePlaylist": "Playlist",
"@recentTypePlaylist": {"description": "Recent access item type - 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": "Playlist: {name}",
"@recentPlaylistInfo": { "@recentPlaylistInfo": {
@@ -1704,6 +1720,10 @@
"@settingsLocalLibrary": {"description": "Settings menu item - local library"}, "@settingsLocalLibrary": {"description": "Settings menu item - local library"},
"settingsLocalLibrarySubtitle": "Scan music & detect duplicates", "settingsLocalLibrarySubtitle": "Scan music & detect duplicates",
"@settingsLocalLibrarySubtitle": {"description": "Subtitle for local library settings"}, "@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": "Local Library",
"@libraryTitle": {"description": "Library settings page title"}, "@libraryTitle": {"description": "Library settings page title"},
"libraryStatus": "Library Status", "libraryStatus": "Library Status",
@@ -2010,5 +2030,157 @@
} }
}, },
"cleanupOrphanedDownloadsNone": "No orphaned entries found", "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"},
"trackSaveCoverArt": "Save Cover Art",
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
"trackSaveCoverArtSubtitle": "Save album art as .jpg file",
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
"trackSaveLyrics": "Save Lyrics (.lrc)",
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
"trackSaveLyricsSubtitle": "Fetch and save lyrics as .lrc file",
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
"trackReEnrich": "Re-enrich Metadata",
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
"trackReEnrichSubtitle": "Re-embed metadata without re-downloading",
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
"trackReEnrichOnlineSubtitle": "Search metadata online and embed into file",
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
"trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
"trackCoverSaved": "Cover art saved to {fileName}",
"@trackCoverSaved": {
"description": "Snackbar after cover art saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackCoverNoSource": "No cover art source available",
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
"trackLyricsSaved": "Lyrics saved to {fileName}",
"@trackLyricsSaved": {
"description": "Snackbar after lyrics saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackReEnrichProgress": "Re-enriching metadata...",
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
"trackReEnrichSearching": "Searching metadata online...",
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
"trackReEnrichFfmpegFailed": "FFmpeg metadata embed failed",
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
"trackSaveFailed": "Failed: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"placeholders": {
"error": {"type": "String"}
}
}
} }
+185 -1
View File
@@ -151,6 +151,14 @@
"@settingsExtensions": { "@settingsExtensions": {
"description": "Settings section - extension management" "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": "Tentang",
"@settingsAbout": { "@settingsAbout": {
"description": "Settings section - app info" "description": "Settings section - app info"
@@ -426,6 +434,10 @@
"@optionsSpotifyWarning": { "@optionsSpotifyWarning": {
"description": "Info about Spotify API requirement" "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": "Ekstensi",
"@extensionsTitle": { "@extensionsTitle": {
"description": "Extensions page title" "description": "Extensions page title"
@@ -2465,6 +2477,18 @@
"@downloadAlbumFolderStructure": { "@downloadAlbumFolderStructure": {
"description": "Setting - album folder organization" "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": "Simpan Format",
"@downloadSaveFormat": { "@downloadSaveFormat": {
"description": "Setting - output file format" "description": "Setting - output file format"
@@ -2727,6 +2751,14 @@
"@recentTypePlaylist": { "@recentTypePlaylist": {
"description": "Recent access item type - playlist" "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": "Playlist: {name}",
"@recentPlaylistInfo": { "@recentPlaylistInfo": {
"description": "Snackbar message when tapping playlist in recent access", "description": "Snackbar message when tapping playlist in recent access",
@@ -3018,5 +3050,157 @@
} }
}, },
"cleanupOrphanedDownloadsNone": "Tidak ada entri unduhan tidak valid", "cleanupOrphanedDownloadsNone": "Tidak ada entri unduhan tidak valid",
"@cleanupOrphanedDownloadsNone": {"description": "Snackbar when no orphans found"} "@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"},
"trackSaveCoverArt": "Simpan Cover Art",
"@trackSaveCoverArt": {"description": "Menu action - save album cover art as file"},
"trackSaveCoverArtSubtitle": "Simpan cover album sebagai file .jpg",
"@trackSaveCoverArtSubtitle": {"description": "Subtitle for save cover art action"},
"trackSaveLyrics": "Simpan Lirik (.lrc)",
"@trackSaveLyrics": {"description": "Menu action - save lyrics as .lrc file"},
"trackSaveLyricsSubtitle": "Ambil dan simpan lirik sebagai file .lrc",
"@trackSaveLyricsSubtitle": {"description": "Subtitle for save lyrics action"},
"trackReEnrich": "Perkaya Ulang Metadata",
"@trackReEnrich": {"description": "Menu action - re-embed metadata into audio file"},
"trackReEnrichSubtitle": "Tanamkan ulang metadata tanpa mengunduh ulang",
"@trackReEnrichSubtitle": {"description": "Subtitle for re-enrich metadata action"},
"trackReEnrichOnlineSubtitle": "Cari metadata dari internet dan tanamkan ke file",
"@trackReEnrichOnlineSubtitle": {"description": "Subtitle for re-enrich metadata action for local items"},
"trackEditMetadata": "Edit Metadata",
"@trackEditMetadata": {"description": "Menu action - edit embedded metadata"},
"trackCoverSaved": "Cover art disimpan ke {fileName}",
"@trackCoverSaved": {
"description": "Snackbar after cover art saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackCoverNoSource": "Tidak ada sumber cover art",
"@trackCoverNoSource": {"description": "Snackbar when no cover art URL or embedded cover"},
"trackLyricsSaved": "Lirik disimpan ke {fileName}",
"@trackLyricsSaved": {
"description": "Snackbar after lyrics saved",
"placeholders": {
"fileName": {"type": "String"}
}
},
"trackReEnrichProgress": "Memperkaya ulang metadata...",
"@trackReEnrichProgress": {"description": "Snackbar while re-enriching metadata"},
"trackReEnrichSearching": "Mencari metadata dari internet...",
"@trackReEnrichSearching": {"description": "Snackbar while searching metadata from internet for local items"},
"trackReEnrichSuccess": "Metadata berhasil diperkaya ulang",
"@trackReEnrichSuccess": {"description": "Snackbar after successful re-enrichment"},
"trackReEnrichFfmpegFailed": "Gagal menanamkan metadata via FFmpeg",
"@trackReEnrichFfmpegFailed": {"description": "Snackbar when FFmpeg embed fails for MP3/Opus"},
"trackSaveFailed": "Gagal: {error}",
"@trackSaveFailed": {
"description": "Snackbar when save operation fails",
"placeholders": {
"error": {"type": "String"}
}
}
} }
+4
View File
@@ -28,6 +28,7 @@ class DownloadItem {
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double speedMBps; final double speedMBps;
final int bytesReceived; // Bytes downloaded so far (for unknown size downloads)
final String? filePath; final String? filePath;
final String? error; final String? error;
final DownloadErrorType? errorType; final DownloadErrorType? errorType;
@@ -41,6 +42,7 @@ class DownloadItem {
this.status = DownloadStatus.queued, this.status = DownloadStatus.queued,
this.progress = 0.0, this.progress = 0.0,
this.speedMBps = 0.0, this.speedMBps = 0.0,
this.bytesReceived = 0,
this.filePath, this.filePath,
this.error, this.error,
this.errorType, this.errorType,
@@ -55,6 +57,7 @@ class DownloadItem {
DownloadStatus? status, DownloadStatus? status,
double? progress, double? progress,
double? speedMBps, double? speedMBps,
int? bytesReceived,
String? filePath, String? filePath,
String? error, String? error,
DownloadErrorType? errorType, DownloadErrorType? errorType,
@@ -68,6 +71,7 @@ class DownloadItem {
status: status ?? this.status, status: status ?? this.status,
progress: progress ?? this.progress, progress: progress ?? this.progress,
speedMBps: speedMBps ?? this.speedMBps, speedMBps: speedMBps ?? this.speedMBps,
bytesReceived: bytesReceived ?? this.bytesReceived,
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
error: error ?? this.error, error: error ?? this.error,
errorType: errorType ?? this.errorType, errorType: errorType ?? this.errorType,
+2
View File
@@ -15,6 +15,7 @@ DownloadItem _$DownloadItemFromJson(Map<String, dynamic> json) => DownloadItem(
DownloadStatus.queued, DownloadStatus.queued,
progress: (json['progress'] as num?)?.toDouble() ?? 0.0, progress: (json['progress'] as num?)?.toDouble() ?? 0.0,
speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0, speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0,
bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0,
filePath: json['filePath'] as String?, filePath: json['filePath'] as String?,
error: json['error'] as String?, error: json['error'] as String?,
errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']),
@@ -30,6 +31,7 @@ Map<String, dynamic> _$DownloadItemToJson(DownloadItem instance) =>
'status': _$DownloadStatusEnumMap[instance.status]!, 'status': _$DownloadStatusEnumMap[instance.status]!,
'progress': instance.progress, 'progress': instance.progress,
'speedMBps': instance.speedMBps, 'speedMBps': instance.speedMBps,
'bytesReceived': instance.bytesReceived,
'filePath': instance.filePath, 'filePath': instance.filePath,
'error': instance.error, 'error': instance.error,
'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType],
+5
View File
@@ -19,6 +19,7 @@ class AppSettings {
final String updateChannel; final String updateChannel;
final bool hasSearchedBefore; final bool hasSearchedBefore;
final String folderOrganization; final String folderOrganization;
final bool useAlbumArtistForFolders;
final String historyViewMode; final String historyViewMode;
final String historyFilterMode; final String historyFilterMode;
final bool askQualityBeforeDownload; final bool askQualityBeforeDownload;
@@ -63,6 +64,7 @@ class AppSettings {
this.updateChannel = 'stable', this.updateChannel = 'stable',
this.hasSearchedBefore = false, this.hasSearchedBefore = false,
this.folderOrganization = 'none', this.folderOrganization = 'none',
this.useAlbumArtistForFolders = true,
this.historyViewMode = 'grid', this.historyViewMode = 'grid',
this.historyFilterMode = 'all', this.historyFilterMode = 'all',
this.askQualityBeforeDownload = true, this.askQualityBeforeDownload = true,
@@ -106,6 +108,7 @@ class AppSettings {
String? updateChannel, String? updateChannel,
bool? hasSearchedBefore, bool? hasSearchedBefore,
String? folderOrganization, String? folderOrganization,
bool? useAlbumArtistForFolders,
String? historyViewMode, String? historyViewMode,
String? historyFilterMode, String? historyFilterMode,
bool? askQualityBeforeDownload, bool? askQualityBeforeDownload,
@@ -149,6 +152,8 @@ class AppSettings {
updateChannel: updateChannel ?? this.updateChannel, updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore, hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization, folderOrganization: folderOrganization ?? this.folderOrganization,
useAlbumArtistForFolders:
useAlbumArtistForFolders ?? this.useAlbumArtistForFolders,
historyViewMode: historyViewMode ?? this.historyViewMode, historyViewMode: historyViewMode ?? this.historyViewMode,
historyFilterMode: historyFilterMode ?? this.historyFilterMode, historyFilterMode: historyFilterMode ?? this.historyFilterMode,
askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload,
+2
View File
@@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
updateChannel: json['updateChannel'] as String? ?? 'stable', updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false, hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none', folderOrganization: json['folderOrganization'] as String? ?? 'none',
useAlbumArtistForFolders: json['useAlbumArtistForFolders'] as bool? ?? true,
historyViewMode: json['historyViewMode'] as String? ?? 'grid', historyViewMode: json['historyViewMode'] as String? ?? 'grid',
historyFilterMode: json['historyFilterMode'] as String? ?? 'all', historyFilterMode: json['historyFilterMode'] as String? ?? 'all',
askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true,
@@ -68,6 +69,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'updateChannel': instance.updateChannel, 'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore, 'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization, 'folderOrganization': instance.folderOrganization,
'useAlbumArtistForFolders': instance.useAlbumArtistForFolders,
'historyViewMode': instance.historyViewMode, 'historyViewMode': instance.historyViewMode,
'historyFilterMode': instance.historyFilterMode, 'historyFilterMode': instance.historyFilterMode,
'askQualityBeforeDownload': instance.askQualityBeforeDownload, 'askQualityBeforeDownload': instance.askQualityBeforeDownload,
+254 -14
View File
@@ -600,11 +600,13 @@ class _ProgressUpdate {
final DownloadStatus status; final DownloadStatus status;
final double progress; final double progress;
final double? speedMBps; final double? speedMBps;
final int? bytesReceived;
const _ProgressUpdate({ const _ProgressUpdate({
required this.status, required this.status,
required this.progress, required this.progress,
this.speedMBps, this.speedMBps,
this.bytesReceived,
}); });
} }
@@ -801,6 +803,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
status: DownloadStatus.downloading, status: DownloadStatus.downloading,
progress: percentage, progress: percentage,
speedMBps: speedMBps, speedMBps: speedMBps,
bytesReceived: bytesReceived,
); );
final mbReceived = bytesReceived / (1024 * 1024); final mbReceived = bytesReceived / (1024 * 1024);
@@ -835,10 +838,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
status: update.status, status: update.status,
progress: update.progress, progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps, speedMBps: update.speedMBps ?? current.speedMBps,
bytesReceived: update.bytesReceived ?? current.bytesReceived,
); );
if (current.status != next.status || if (current.status != next.status ||
current.progress != next.progress || current.progress != next.progress ||
current.speedMBps != next.speedMBps) { current.speedMBps != next.speedMBps ||
current.bytesReceived != next.bytesReceived) {
if (!changed) { if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems); updatedItems = List<DownloadItem>.from(updatedItems);
changed = true; changed = true;
@@ -1027,14 +1032,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, { String folderOrganization, {
bool separateSingles = false, bool separateSingles = false,
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
}) async { }) async {
String baseDir = state.outputDir; String baseDir = state.outputDir;
final albumArtist = final folderArtist = useAlbumArtistForFolders
_normalizeOptionalString(track.albumArtist) ?? track.artistName; ? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) { if (isSingle) {
@@ -1092,7 +1099,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String subPath = ''; String subPath = '';
switch (folderOrganization) { switch (folderOrganization) {
case 'artist': case 'artist':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
subPath = artistName; subPath = artistName;
break; break;
case 'album': case 'album':
@@ -1100,7 +1107,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
subPath = albumName; subPath = albumName;
break; break;
case 'artist_album': case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
subPath = '$artistName${Platform.pathSeparator}$albumName'; subPath = '$artistName${Platform.pathSeparator}$albumName';
break; break;
@@ -1144,13 +1151,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
String folderOrganization, { String folderOrganization, {
bool separateSingles = false, bool separateSingles = false,
String albumFolderStructure = 'artist_album', String albumFolderStructure = 'artist_album',
bool useAlbumArtistForFolders = true,
}) async { }) async {
final albumArtist = final folderArtist = useAlbumArtistForFolders
_normalizeOptionalString(track.albumArtist) ?? track.artistName; ? _normalizeOptionalString(track.albumArtist) ?? track.artistName
: track.artistName;
if (separateSingles) { if (separateSingles) {
final isSingle = track.isSingle; final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
if (albumFolderStructure == 'artist_album_singles') { if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) { if (isSingle) {
@@ -1186,11 +1195,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
switch (folderOrganization) { switch (folderOrganization) {
case 'artist': case 'artist':
return _sanitizeFolderName(albumArtist); return _sanitizeFolderName(folderArtist);
case 'album': case 'album':
return _sanitizeFolderName(track.albumName); return _sanitizeFolderName(track.albumName);
case 'artist_album': case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist); final artistName = _sanitizeFolderName(folderArtist);
final albumName = _sanitizeFolderName(track.albumName); final albumName = _sanitizeFolderName(track.albumName);
return '$artistName/$albumName'; return '$artistName/$albumName';
default: default:
@@ -1199,6 +1208,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String _determineOutputExt(String quality, String service) { String _determineOutputExt(String quality, String service) {
// YouTube provider - lossy only (Opus or MP3)
if (service.toLowerCase() == 'youtube') {
if (quality.toLowerCase().contains('mp3')) {
return '.mp3';
}
return '.opus';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a'; return '.m4a';
} }
@@ -1214,8 +1230,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
case '.opus': case '.opus':
return 'audio/ogg'; return 'audio/ogg';
case '.flac': case '.flac':
default:
return 'audio/flac'; return 'audio/flac';
case '.lrc':
return 'application/octet-stream';
default:
return 'application/octet-stream';
} }
} }
@@ -1234,6 +1253,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
return match?.group(1); return match?.group(1);
} }
static final _isrcRegex = RegExp(r'^[A-Z]{2}[A-Z0-9]{3}\d{2}\d{5}$');
bool _isValidISRC(String value) {
return _isrcRegex.hasMatch(value.toUpperCase());
}
void updateSettings(AppSettings settings) { void updateSettings(AppSettings settings) {
final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5); final concurrentDownloads = settings.concurrentDownloads.clamp(1, 5);
state = state.copyWith( state = state.copyWith(
@@ -2163,7 +2188,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
treeUri: treeUri, treeUri: treeUri,
relativeDir: relativeDir, relativeDir: relativeDir,
fileName: lrcName, fileName: lrcName,
mimeType: 'text/plain', mimeType: _mimeTypeForExt('.lrc'),
srcPath: tempPath, srcPath: tempPath,
); );
if (uri != null) { if (uri != null) {
@@ -2252,6 +2277,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await musicDir.create(recursive: true); await musicDir.create(recursive: true);
} }
state = state.copyWith(outputDir: musicDir.path); state = state.copyWith(outputDir: musicDir.path);
} else if (!isValidIosWritablePath(state.outputDir)) {
// Check for other invalid paths (like container root without Documents/)
_log.w(
'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
);
_log.w('Original path: ${state.outputDir}');
final correctedPath = await validateOrFixIosPath(state.outputDir);
_log.i('Corrected path: $correctedPath');
state = state.copyWith(outputDir: correctedPath);
} }
} }
@@ -2530,6 +2564,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
) )
: ''; : '';
String? appOutputDir; String? appOutputDir;
@@ -2540,6 +2575,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
); );
var effectiveOutputDir = initialOutputDir; var effectiveOutputDir = initialOutputDir;
var effectiveSafMode = isSafMode; var effectiveSafMode = isSafMode;
@@ -2577,7 +2613,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (deezerTrackId == null && if (deezerTrackId == null &&
trackToDownload.isrc != null && trackToDownload.isrc != null &&
trackToDownload.isrc!.isNotEmpty) { trackToDownload.isrc!.isNotEmpty &&
_isValidISRC(trackToDownload.isrc!)) {
try { try {
_log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}'); _log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}');
final deezerResult = await PlatformBridge.searchDeezerByISRC( final deezerResult = await PlatformBridge.searchDeezerByISRC(
@@ -2593,6 +2630,75 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
} }
// Fallback: Use SongLink to convert Spotify ID to Deezer ID
if (deezerTrackId == null &&
trackToDownload.id.isNotEmpty &&
!trackToDownload.id.startsWith('deezer:') &&
!trackToDownload.id.startsWith('extension:')) {
try {
// Extract clean Spotify ID (remove spotify: prefix if present)
String spotifyId = trackToDownload.id;
if (spotifyId.startsWith('spotify:track:')) {
spotifyId = spotifyId.split(':').last;
}
_log.d('No Deezer ID, converting from Spotify via SongLink: $spotifyId');
final deezerData = await PlatformBridge.convertSpotifyToDeezer('track', spotifyId);
// Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}}
final trackData = deezerData['track'];
if (trackData is Map<String, dynamic>) {
final rawId = trackData['spotify_id'] as String?;
if (rawId != null && rawId.startsWith('deezer:')) {
deezerTrackId = rawId.split(':')[1];
_log.d('Found Deezer track ID via SongLink: $deezerTrackId');
} else if (deezerData['id'] != null) {
deezerTrackId = deezerData['id'].toString();
_log.d('Found Deezer track ID via SongLink (legacy): $deezerTrackId');
}
// Enrich track metadata from Deezer response (release_date, isrc, etc.)
final deezerReleaseDate = _normalizeOptionalString(trackData['release_date'] as String?);
final deezerIsrc = _normalizeOptionalString(trackData['isrc'] as String?);
final deezerTrackNum = trackData['track_number'] as int?;
final deezerDiscNum = trackData['disc_number'] as int?;
final needsEnrich =
(trackToDownload.releaseDate == null && deezerReleaseDate != null) ||
(trackToDownload.isrc == null && deezerIsrc != null) ||
(!_isValidISRC(trackToDownload.isrc ?? '') && deezerIsrc != null) ||
(trackToDownload.trackNumber == null && deezerTrackNum != null) ||
(trackToDownload.discNumber == null && deezerDiscNum != null);
if (needsEnrich) {
trackToDownload = Track(
id: trackToDownload.id,
name: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: trackToDownload.albumArtist,
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc
: trackToDownload.isrc,
trackNumber: trackToDownload.trackNumber ?? deezerTrackNum,
discNumber: trackToDownload.discNumber ?? deezerDiscNum,
releaseDate: trackToDownload.releaseDate ?? deezerReleaseDate,
deezerId: deezerTrackId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
source: trackToDownload.source,
);
_log.d('Enriched track from Deezer - date: ${trackToDownload.releaseDate}, ISRC: ${trackToDownload.isrc}, track: ${trackToDownload.trackNumber}, disc: ${trackToDownload.discNumber}');
}
} else if (deezerData['id'] != null) {
deezerTrackId = deezerData['id'].toString();
_log.d('Found Deezer track ID via SongLink (flat): $deezerTrackId');
}
} catch (e) {
_log.w('Failed to convert Spotify to Deezer via SongLink: $e');
}
}
if (deezerTrackId != null && deezerTrackId.isNotEmpty) { if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
try { try {
final extendedMetadata = final extendedMetadata =
@@ -2628,6 +2734,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final fileName = useSaf ? (safFileName ?? '') : ''; final fileName = useSaf ? (safFileName ?? '') : '';
final outputExt = useSaf ? safOutputExt : ''; final outputExt = useSaf ? safOutputExt : '';
// YouTube provider - lossy only, bypasses fallback chain
if (item.service == 'youtube') {
_log.d('Using YouTube/Cobalt provider for download');
_log.d('Quality: $quality (lossy only)');
_log.d('Output dir: $outputDir');
return PlatformBridge.downloadFromYouTube(
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: quality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
itemId: item.id,
durationMs: trackToDownload.duration,
isrc: trackToDownload.isrc,
spotifyId: trackToDownload.id,
deezerId: deezerTrackId,
storageMode: storageMode,
safTreeUri: treeUri,
safRelativeDir: relativeDir,
safFileName: fileName,
safOutputExt: outputExt,
);
}
if (useExtensions) { if (useExtensions) {
_log.d('Using extension providers for download'); _log.d('Using extension providers for download');
_log.d( _log.d(
@@ -2736,6 +2872,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
settings.folderOrganization, settings.folderOrganization,
separateSingles: settings.separateSingles, separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure, albumFolderStructure: settings.albumFolderStructure,
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
); );
final fallbackResult = await runDownload( final fallbackResult = await runDownload(
useSaf: false, useSaf: false,
@@ -3226,6 +3363,109 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await File(tempPath).delete(); await File(tempPath).delete();
} catch (_) {} } catch (_) {}
} }
}
}
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt
if (!wasExisting &&
item.service == 'youtube' &&
filePath != null) {
final isOpusFile = filePath.endsWith('.opus');
final isMp3File = filePath.endsWith('.mp3');
if (isOpusFile || isMp3File) {
_log.i('YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
final isContentUriPath = isContentUri(filePath);
if (isContentUriPath && effectiveSafMode) {
// SAF mode: copy to temp, embed, write back
final tempPath = await _copySafToTemp(filePath);
if (tempPath != null) {
try {
if (isMp3File) {
await _embedMetadataToMp3(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
tempPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
// Write back to SAF
final ext = isMp3File ? '.mp3' : '.opus';
final newFileName = '${safBaseName ?? 'track'}$ext';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt(ext),
srcPath: tempPath,
);
if (newUri != null) {
if (newUri != filePath) {
await _deleteSafFile(filePath);
}
filePath = newUri;
finalSafFileName = newFileName;
_log.d('YouTube SAF metadata embedding completed');
} else {
_log.w('Failed to write metadata-updated file back to SAF');
}
} catch (e) {
_log.w('YouTube SAF metadata embedding failed: $e');
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
}
}
} else {
// Non-SAF mode: embed directly
try {
if (isMp3File) {
await _embedMetadataToMp3(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
} else {
await _embedMetadataToOpus(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
}
_log.d('YouTube metadata embedding completed');
} catch (e) {
_log.w('YouTube metadata embedding failed: $e');
}
}
} }
} }
+29 -6
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('LocalLibrary'); final _log = AppLogger('LocalLibrary');
const _lastScannedAtKey = 'local_library_last_scanned_at'; const _lastScannedAtKey = 'local_library_last_scanned_at';
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
class LocalLibraryState { class LocalLibraryState {
final List<LocalLibraryItem> items; final List<LocalLibraryItem> items;
@@ -22,6 +23,7 @@ class LocalLibraryState {
final int scanErrorCount; final int scanErrorCount;
final bool scanWasCancelled; final bool scanWasCancelled;
final DateTime? lastScannedAt; final DateTime? lastScannedAt;
final int excludedDownloadedCount;
final Set<String> _isrcSet; final Set<String> _isrcSet;
final Set<String> _trackKeySet; final Set<String> _trackKeySet;
final Map<String, LocalLibraryItem> _byIsrc; final Map<String, LocalLibraryItem> _byIsrc;
@@ -36,6 +38,7 @@ class LocalLibraryState {
this.scanErrorCount = 0, this.scanErrorCount = 0,
this.scanWasCancelled = false, this.scanWasCancelled = false,
this.lastScannedAt, this.lastScannedAt,
this.excludedDownloadedCount = 0,
}) : _isrcSet = items }) : _isrcSet = items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty) .where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => item.isrc!) .map((item) => item.isrc!)
@@ -81,6 +84,7 @@ class LocalLibraryState {
int? scanErrorCount, int? scanErrorCount,
bool? scanWasCancelled, bool? scanWasCancelled,
DateTime? lastScannedAt, DateTime? lastScannedAt,
int? excludedDownloadedCount,
}) { }) {
return LocalLibraryState( return LocalLibraryState(
items: items ?? this.items, items: items ?? this.items,
@@ -92,6 +96,8 @@ class LocalLibraryState {
scanErrorCount: scanErrorCount ?? this.scanErrorCount, scanErrorCount: scanErrorCount ?? this.scanErrorCount,
scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled,
lastScannedAt: lastScannedAt ?? this.lastScannedAt, 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(); final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
DateTime? lastScannedAt; DateTime? lastScannedAt;
var excludedDownloadedCount = 0;
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final lastScannedAtStr = prefs.getString(_lastScannedAtKey); final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) { if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
lastScannedAt = DateTime.tryParse(lastScannedAtStr); lastScannedAt = DateTime.tryParse(lastScannedAtStr);
} }
excludedDownloadedCount =
prefs.getInt(_excludedDownloadedCountKey) ?? 0;
} catch (e) { } catch (e) {
_log.w('Failed to load lastScannedAt: $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( _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) { } catch (e, stack) {
_log.e('Failed to load library from database: $e', e, stack); _log.e('Failed to load library from database: $e', e, stack);
@@ -174,8 +188,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
try { try {
final cacheDir = await getApplicationCacheDirectory(); final appSupportDir = await getApplicationSupportDirectory();
final coverCacheDir = '${cacheDir.path}/library_covers'; final coverCacheDir = '${appSupportDir.path}/library_covers';
await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir); await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir);
_log.i('Cover cache directory set to: $coverCacheDir'); _log.i('Cover cache directory set to: $coverCacheDir');
} catch (e) { } catch (e) {
@@ -226,6 +240,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String()); await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now'); _log.d('Saved lastScannedAt: $now');
} catch (e) { } catch (e) {
_log.w('Failed to save lastScannedAt: $e'); _log.w('Failed to save lastScannedAt: $e');
@@ -237,9 +252,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, 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 { } else {
// Incremental scan path - only scans new/modified files // Incremental scan path - only scans new/modified files
final existingFiles = await _db.getFileModTimes(); final existingFiles = await _db.getFileModTimes();
@@ -344,6 +363,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_lastScannedAtKey, now.toIso8601String()); await prefs.setString(_lastScannedAtKey, now.toIso8601String());
await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads);
_log.d('Saved lastScannedAt: $now'); _log.d('Saved lastScannedAt: $now');
} catch (e) { } catch (e) {
_log.w('Failed to save lastScannedAt: $e'); _log.w('Failed to save lastScannedAt: $e');
@@ -355,11 +375,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
); );
_log.i( _log.i(
'Incremental scan complete: ${items.length} total tracks ' '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) { } catch (e, stack) {
@@ -427,6 +449,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_lastScannedAtKey); await prefs.remove(_lastScannedAtKey);
await prefs.remove(_excludedDownloadedCountKey);
} catch (e) { } catch (e) {
_log.w('Failed to clear lastScannedAt: $e'); _log.w('Failed to clear lastScannedAt: $e');
} }
+5
View File
@@ -226,6 +226,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setUseAlbumArtistForFolders(bool enabled) {
state = state.copyWith(useAlbumArtistForFolders: enabled);
_saveSettings();
}
void setHistoryViewMode(String mode) { void setHistoryViewMode(String mode) {
state = state.copyWith(historyViewMode: mode); state = state.copyWith(historyViewMode: mode);
_saveSettings(); _saveSettings();
+14 -7
View File
@@ -233,11 +233,17 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; 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( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -259,7 +265,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = final collapseRatio =
(constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); (constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
@@ -292,7 +299,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
height: 80, height: bottomGradientHeight,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -311,7 +318,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -338,7 +345,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
Icons.album, Icons.album,
size: 64, size: fallbackIconSize,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
+82 -32
View File
@@ -113,6 +113,37 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> _singlesBucket = const []; List<ArtistAlbum> _singlesBucket = const [];
List<ArtistAlbum> _compilationsBucket = 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -1412,6 +1443,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
List<ArtistAlbum> albums, List<ArtistAlbum> albums,
ColorScheme colorScheme, ColorScheme colorScheme,
) { ) {
final sectionHeight = _artistAlbumSectionHeight();
final tileSize = _artistAlbumTileSize();
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -1425,7 +1459,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
), ),
), ),
SizedBox( SizedBox(
height: 220, height: sectionHeight,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
@@ -1434,7 +1468,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
final album = albums[index]; final album = albums[index];
return KeyedSubtree( return KeyedSubtree(
key: ValueKey(album.id), 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); final isSelected = _selectedAlbumIds.contains(album.id);
return GestureDetector( return GestureDetector(
@@ -1460,7 +1499,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
} }
}, },
child: Container( child: Container(
width: 140, width: tileSize,
height: sectionHeight,
margin: const EdgeInsets.symmetric(horizontal: 4), margin: const EdgeInsets.symmetric(horizontal: 4),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1472,19 +1512,19 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
child: album.coverUrl != null child: album.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: album.coverUrl!, imageUrl: album.coverUrl!,
width: 140, width: tileSize,
height: 140, height: tileSize,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 280, memCacheWidth: (tileSize * 2).round(),
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
placeholder: (context, url) => Container( placeholder: (context, url) => Container(
width: 140, width: tileSize,
height: 140, height: tileSize,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
), ),
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
width: 140, width: tileSize,
height: 140, height: tileSize,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
Icons.album, Icons.album,
@@ -1494,8 +1534,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
), ),
) )
: Container( : Container(
width: 140, width: tileSize,
height: 140, height: tileSize,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
Icons.album, Icons.album,
@@ -1553,26 +1593,36 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Expanded(
album.name, child: Column(
style: Theme.of( crossAxisAlignment: CrossAxisAlignment.start,
context, mainAxisSize: MainAxisSize.min,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), children: [
maxLines: 2, Flexible(
overflow: TextOverflow.ellipsis, child: Text(
), album.name,
const SizedBox(height: 2), style: Theme.of(
Text( context,
album.totalTracks > 0 ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' maxLines: 2,
: album.releaseDate.length >= 4 overflow: TextOverflow.ellipsis,
? album.releaseDate.substring(0, 4) ),
: album.releaseDate, ),
style: Theme.of(context).textTheme.bodySmall?.copyWith( const SizedBox(height: 2),
color: colorScheme.onSurfaceVariant, 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,
), ),
], ],
), ),
+262 -105
View File
@@ -23,7 +23,8 @@ class DownloadedAlbumScreen extends ConsumerStatefulWidget {
}); });
@override @override
ConsumerState<DownloadedAlbumScreen> createState() => _DownloadedAlbumScreenState(); ConsumerState<DownloadedAlbumScreen> createState() =>
_DownloadedAlbumScreenState();
} }
class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> { class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
@@ -53,27 +54,31 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
/// Get tracks for this album from history provider (reactive) /// 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) { return allItems.where((item) {
// Use albumArtist if available and not empty, otherwise artistName // Use albumArtist if available and not empty, otherwise artistName
final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) final itemArtist =
(item.albumArtist != null && item.albumArtist!.isNotEmpty)
? item.albumArtist! ? item.albumArtist!
: item.artistName; : item.artistName;
// Use lowercase for case-insensitive matching // Use lowercase for case-insensitive matching
final itemKey = '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; final itemKey =
final albumKey = '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}';
final albumKey =
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
return itemKey == albumKey; return itemKey == albumKey;
}).toList() }).toList()..sort((a, b) {
..sort((a, b) { // Sort by disc number first, then by track number
// Sort by disc number first, then by track number final aDisc = a.discNumber ?? 1;
final aDisc = a.discNumber ?? 1; final bDisc = b.discNumber ?? 1;
final bDisc = b.discNumber ?? 1; if (aDisc != bDisc) return aDisc.compareTo(bDisc);
if (aDisc != bDisc) return aDisc.compareTo(bDisc); final aNum = a.trackNumber ?? 999;
final aNum = a.trackNumber ?? 999; final bNum = b.trackNumber ?? 999;
final bNum = b.trackNumber ?? 999; if (aNum != bNum) return aNum.compareTo(bNum);
if (aNum != bNum) return aNum.compareTo(bNum); return a.trackName.compareTo(b.trackName);
return a.trackName.compareTo(b.trackName); });
});
} }
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc( Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
@@ -164,7 +169,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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) { void _navigateToMetadataScreen(DownloadHistoryItem item) {
_precacheCover(item.coverUrl); _precacheCover(item.coverUrl);
Navigator.push(context, PageRouteBuilder( Navigator.push(
transitionDuration: const Duration(milliseconds: 300), context,
reverseTransitionDuration: const Duration(milliseconds: 250), PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => TrackMetadataScreen(item: item), transitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), 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) { void _precacheCover(String? url) {
@@ -208,18 +222,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final bottomPadding = MediaQuery.of(context).padding.bottom; 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); final tracks = _getAlbumTracks(allHistoryItems);
// Show empty state if no tracks found // Show empty state if no tracks found
if (tracks.isEmpty) { if (tracks.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(widget.albumName)),
title: Text(widget.albumName), body: Center(child: Text('No tracks found for this album')),
),
body: Center(
child: Text('No tracks found for this album'),
),
); );
} }
@@ -248,7 +260,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks), _buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
], ],
), ),
@@ -258,7 +272,12 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
left: 0, left: 0,
right: 0, right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), 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) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; // 50% of screen width 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( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, // Use theme color for collapsed state backgroundColor:
colorScheme.surface, // Use theme color for collapsed state
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
title: AnimatedOpacity( title: AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
@@ -292,7 +318,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
@@ -306,25 +334,35 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
placeholder: (_, _) => Container(color: colorScheme.surface), placeholder: (_, _) =>
errorWidget: (_, _, _) => Container(color: colorScheme.surface), Container(color: colorScheme.surface),
errorWidget: (_, _, _) =>
Container(color: colorScheme.surface),
) )
else else
Container(color: colorScheme.surface), Container(color: colorScheme.surface),
ClipRect( ClipRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), 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( Positioned(
left: 0, right: 0, bottom: 0, height: 80, left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, 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, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -352,7 +390,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: widget.coverUrl != null child: widget.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.coverUrl!, imageUrl: widget.coverUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: (coverSize * 2).toInt(), memCacheWidth: (coverSize * 2).toInt(),
@@ -360,7 +398,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, 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( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), 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), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), 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( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -399,32 +453,59 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [ children: [
Text( Text(
widget.albumName, 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), const SizedBox(height: 4),
Text( Text(
widget.artistName, 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), const SizedBox(height: 12),
Row( Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.download_done, size: 14, color: colorScheme.onPrimaryContainer), Icon(
Icons.download_done,
size: 14,
color: colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4), 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), const SizedBox(width: 8),
if (_getCommonQuality(tracks) != null) if (_getCommonQuality(tracks) != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24') color: _getCommonQuality(tracks)!.startsWith('24')
? colorScheme.tertiaryContainer ? colorScheme.tertiaryContainer
@@ -462,7 +543,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
return firstQuality; return firstQuality;
} }
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<DownloadHistoryItem> tracks) { Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -470,14 +555,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
children: [ children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary), Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8), 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(), const Spacer(),
if (!_isSelectionMode) if (!_isSelectionMode)
TextButton.icon( 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), icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect), 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); final discMap = _groupTracksByDisc(tracks);
if (discMap.length <= 1) { if (discMap.length <= 1) {
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { final track = tracks[index];
final track = tracks[index]; return KeyedSubtree(
return KeyedSubtree( key: ValueKey(track.id),
key: ValueKey(track.id), child: _buildTrackItem(context, colorScheme, track),
child: _buildTrackItem(context, colorScheme, track), );
); }, childCount: tracks.length),
},
childCount: tracks.length,
),
); );
} }
@@ -524,12 +620,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
} }
} }
return SliverList( return SliverList(delegate: SliverChildListDelegate(children));
delegate: SliverChildListDelegate(children),
);
} }
Widget _buildDiscSeparator(BuildContext context, ColorScheme colorScheme, int discNumber) { Widget _buildDiscSeparator(
BuildContext context,
ColorScheme colorScheme,
int discNumber,
) {
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row( child: Row(
@@ -543,7 +641,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer), Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
context.l10n.downloadedAlbumDiscHeader(discNumber), 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); final isSelected = _selectedIds.contains(track.id);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card( child: Card(
elevation: 0, 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), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _navigateToMetadataScreen(track), : () => _navigateToMetadataScreen(track),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -590,12 +702,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
width: 24, width: 24,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent, color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle, 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 child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) ? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null, : null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -617,7 +740,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
track.trackName, track.trackName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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( subtitle: Text(
track.artistName, track.artistName,
@@ -625,19 +750,28 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle(color: colorScheme.onSurfaceVariant), style: TextStyle(color: colorScheme.onSurfaceVariant),
), ),
trailing: _isSelectionMode ? null : IconButton( trailing: _isSelectionMode
onPressed: () => _openFile(track.filePath), ? null
icon: Icon(Icons.play_arrow, color: colorScheme.primary), : IconButton(
style: IconButton.styleFrom( onPressed: () => _openFile(track.filePath),
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), 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 selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
@@ -684,12 +818,18 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount), context.l10n.downloadedAlbumSelectedCount(
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
Text( Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect, allSelected
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ? 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); _selectAll(tracks);
} }
}, },
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20), icon: Icon(
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), allSelected ? Icons.deselect : Icons.select_all,
style: TextButton.styleFrom(foregroundColor: colorScheme.primary), size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
), ),
], ],
), ),
@@ -712,7 +861,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
label: Text( label: Text(
selectedCount > 0 selectedCount > 0
@@ -720,10 +871,16 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
: context.l10n.downloadedAlbumSelectToDelete, : context.l10n.downloadedAlbumSelectToDelete,
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, backgroundColor: selectedCount > 0
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, ? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
), ),
), ),
+244 -84
View File
@@ -19,6 +19,7 @@ import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/artist_screen.dart'; import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/services/csv_import_service.dart'; import 'package:spotiflac_android/services/csv_import_service.dart';
import 'package:spotiflac_android/services/platform_bridge.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/utils/file_access.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
@@ -43,6 +44,16 @@ class _RecentAccessView {
}); });
} }
class _CsvImportOptions {
final bool confirmed;
final bool skipDownloaded;
const _CsvImportOptions({
required this.confirmed,
required this.skipDownloaded,
});
}
class _HomeTabState extends ConsumerState<HomeTab> class _HomeTabState extends ConsumerState<HomeTab>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
final _urlController = TextEditingController(); final _urlController = TextEditingController();
@@ -64,6 +75,50 @@ class _HomeTabState extends ConsumerState<HomeTab>
Set<String>? _recentAccessHiddenIdsCache; Set<String>? _recentAccessHiddenIdsCache;
_RecentAccessView? _recentAccessViewCache; _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 @override
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
@@ -143,6 +198,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
void _onSearchFocusChanged() { void _onSearchFocusChanged() {
if (mounted) {
setState(() {});
}
if (_searchFocusNode.hasFocus) { if (_searchFocusNode.hasFocus) {
ref.read(trackProvider.notifier).setShowingRecentAccess(true); ref.read(trackProvider.notifier).setShowingRecentAccess(true);
} }
@@ -314,6 +372,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (trackState.albumId != null && if (trackState.albumId != null &&
trackState.albumName != null && trackState.albumName != null &&
trackState.tracks.isNotEmpty) { trackState.tracks.isNotEmpty) {
final extensionId = trackState.searchExtensionId;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -322,6 +381,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
albumName: trackState.albumName!, albumName: trackState.albumName!,
coverUrl: trackState.coverUrl, coverUrl: trackState.coverUrl,
tracks: trackState.tracks, tracks: trackState.tracks,
extensionId: extensionId,
), ),
), ),
); );
@@ -338,7 +398,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
id: trackState.playlistName!, id: trackState.playlistName!,
name: trackState.playlistName!, name: trackState.playlistName!,
imageUrl: trackState.coverUrl, imageUrl: trackState.coverUrl,
providerId: 'spotify', providerId: trackState.searchExtensionId ?? 'spotify',
); );
Navigator.push( Navigator.push(
@@ -360,6 +420,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (trackState.artistId != null && if (trackState.artistId != null &&
trackState.artistName != null && trackState.artistName != null &&
trackState.artistAlbums != null) { trackState.artistAlbums != null) {
final extensionId = trackState.searchExtensionId;
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@@ -368,6 +429,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
artistName: trackState.artistName!, artistName: trackState.artistName!,
coverUrl: trackState.coverUrl, coverUrl: trackState.coverUrl,
albums: trackState.artistAlbums!, albums: trackState.artistAlbums!,
extensionId: extensionId,
), ),
), ),
); );
@@ -475,19 +537,118 @@ class _HomeTabState extends ConsumerState<HomeTab>
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
final l10n = context.l10n; final l10n = context.l10n;
final options = await showDialog<_CsvImportOptions>(
context: this.context,
builder: (dialogCtx) {
var skipDownloaded = true;
return StatefulBuilder(
builder: (dialogCtx, setDialogState) => AlertDialog(
title: Text(l10n.dialogImportPlaylistTitle),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.dialogImportPlaylistMessage(tracks.length)),
const SizedBox(height: 12),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Skip already downloaded songs'),
value: skipDownloaded,
onChanged: (value) {
setDialogState(() {
skipDownloaded = value ?? true;
});
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(
dialogCtx,
const _CsvImportOptions(
confirmed: false,
skipDownloaded: true,
),
),
child: Text(l10n.dialogCancel),
),
FilledButton(
onPressed: () => Navigator.pop(
dialogCtx,
_CsvImportOptions(
confirmed: true,
skipDownloaded: skipDownloaded,
),
),
child: Text(l10n.dialogImport),
),
],
),
);
},
);
if (options == null || !options.confirmed) return;
var tracksToQueue = tracks;
var skippedDownloadedCount = 0;
if (options.skipDownloaded) {
final historyState = ref.read(downloadHistoryProvider);
tracksToQueue = [];
for (final track in tracks) {
final isDownloaded =
historyState.isDownloaded(track.id) ||
(track.isrc != null &&
historyState.getByIsrc(track.isrc!) != null);
if (isDownloaded) {
skippedDownloadedCount++;
continue;
}
tracksToQueue.add(track);
}
}
if (tracksToQueue.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(
l10n.discographySkippedDownloaded(0, skippedDownloadedCount),
),
),
);
}
return;
}
final queueSnackbarMessage = skippedDownloadedCount > 0
? l10n.discographySkippedDownloaded(
tracksToQueue.length,
skippedDownloadedCount,
)
: l10n.snackbarAddedTracksToQueue(tracksToQueue.length);
if (!mounted) return;
if (settings.askQualityBeforeDownload) { if (settings.askQualityBeforeDownload) {
DownloadServicePicker.show( DownloadServicePicker.show(
this.context, this.context,
trackName: l10n.csvImportTracks(tracks.length), trackName: l10n.csvImportTracks(tracksToQueue.length),
artistName: l10n.dialogImportPlaylistTitle, artistName: l10n.dialogImportPlaylistTitle,
onSelect: (quality, service) { onSelect: (quality, service) {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, service, qualityOverride: quality); .addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar( ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar( SnackBar(
content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)), content: Text(queueSnackbarMessage),
action: SnackBarAction( action: SnackBarAction(
label: l10n.snackbarViewQueue, label: l10n.snackbarViewQueue,
onPressed: () {}, onPressed: () {},
@@ -498,39 +659,19 @@ class _HomeTabState extends ConsumerState<HomeTab>
}, },
); );
} else { } else {
final confirmed = await showDialog<bool>( ref
context: this.context, .read(downloadQueueProvider.notifier)
builder: (dialogCtx) => AlertDialog( .addMultipleToQueue(tracksToQueue, settings.defaultService);
title: Text(l10n.dialogImportPlaylistTitle), if (mounted) {
content: Text(l10n.dialogImportPlaylistMessage(tracks.length)), ScaffoldMessenger.of(this.context).showSnackBar(
actions: [ SnackBar(
TextButton( content: Text(queueSnackbarMessage),
onPressed: () => Navigator.pop(dialogCtx, false), action: SnackBarAction(
child: Text(l10n.dialogCancel), label: l10n.snackbarViewQueue,
onPressed: () {},
), ),
FilledButton( ),
onPressed: () => Navigator.pop(dialogCtx, true), );
child: Text(l10n.dialogImport),
),
],
),
);
if (confirmed == true) {
ref
.read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracks, settings.defaultService);
if (mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
SnackBar(
content: Text(l10n.snackbarAddedTracksToQueue(tracks.length)),
action: SnackBarAction(
label: l10n.snackbarViewQueue,
onPressed: () {},
),
),
);
}
} }
} }
} }
@@ -575,13 +716,17 @@ class _HomeTabState extends ConsumerState<HomeTab>
(searchArtists != null && searchArtists.isNotEmpty) || (searchArtists != null && searchArtists.isNotEmpty) ||
(searchAlbums != null && searchAlbums.isNotEmpty) || (searchAlbums != null && searchAlbums.isNotEmpty) ||
(searchPlaylists != null && searchPlaylists.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( final isShowingRecentAccess = ref.watch(
trackProvider.select((s) => s.isShowingRecentAccess), trackProvider.select((s) => s.isShowingRecentAccess),
); );
final hasResults = isShowingRecentAccess || hasActualResults || isLoading;
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final screenHeight = mediaQuery.size.height; final screenHeight = mediaQuery.size.height;
final topPadding = mediaQuery.padding.top; final topPadding = normalizedHeaderTopPadding(context);
final historyItems = ref.watch( final historyItems = ref.watch(
downloadHistoryProvider.select((s) => s.items), downloadHistoryProvider.select((s) => s.items),
); );
@@ -592,13 +737,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
recentAccessProvider.select((s) => s.hiddenDownloadIds), recentAccessProvider.select((s) => s.hiddenDownloadIds),
); );
final hasRecentItems = final recentModeRequested = isShowingRecentAccess || isSearchFocused;
recentAccessItems.isNotEmpty || historyItems.isNotEmpty;
final showRecentAccess = final showRecentAccess =
isShowingRecentAccess && recentModeRequested &&
hasRecentItems && (!hasSearchInput || hasShortSearchInput || !hasActualResults) &&
!hasActualResults &&
!isLoading; !isLoading;
final hasResults =
hasSearchInput || hasActualResults || isLoading || showRecentAccess;
final recentAccessView = showRecentAccess final recentAccessView = showRecentAccess
? _getRecentAccessView( ? _getRecentAccessView(
recentAccessItems, recentAccessItems,
@@ -665,7 +810,10 @@ class _HomeTabState extends ConsumerState<HomeTab>
]; ];
} }
if (hasActualResults && isShowingRecentAccess) { if (hasActualResults &&
isShowingRecentAccess &&
hasSearchInput &&
!isSearchFocused) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
ref.read(trackProvider.notifier).setShowingRecentAccess(false); ref.read(trackProvider.notifier).setShowingRecentAccess(false);
@@ -784,7 +932,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
// Search filter bar (only shown when has search results) // Search filter bar (only shown when has search results)
if (searchFilters.isNotEmpty && hasActualResults) if (searchFilters.isNotEmpty && hasActualResults && !showRecentAccess)
SliverToBoxAdapter( SliverToBoxAdapter(
child: _buildSearchFilterBar( child: _buildSearchFilterBar(
searchFilters, searchFilters,
@@ -862,7 +1010,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
isLoading: isLoading, isLoading: isLoading,
error: error, error: error,
colorScheme: colorScheme, colorScheme: colorScheme,
hasResults: hasResults, hasResults: hasActualResults || isLoading,
searchExtensionId: searchExtensionId, searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator, showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
@@ -879,6 +1027,8 @@ class _HomeTabState extends ConsumerState<HomeTab>
ColorScheme colorScheme, ColorScheme colorScheme,
) { ) {
final itemCount = items.length < 10 ? items.length : 10; final itemCount = items.length < 10 ? items.length : 10;
final coverSize = _recentDownloadCoverSize(context);
final rowHeight = _recentDownloadsRowHeight(context);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -893,7 +1043,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
), ),
SizedBox( SizedBox(
height: 130, height: rowHeight,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: itemCount, itemCount: itemCount,
@@ -904,7 +1054,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
child: GestureDetector( child: GestureDetector(
onTap: () => _navigateToMetadataScreen(item), onTap: () => _navigateToMetadataScreen(item),
child: Container( child: Container(
width: 100, width: coverSize,
margin: const EdgeInsets.only(right: 12), margin: const EdgeInsets.only(right: 12),
child: Column( child: Column(
children: [ children: [
@@ -913,16 +1063,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
child: item.coverUrl != null child: item.coverUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: item.coverUrl!, imageUrl: item.coverUrl!,
width: 100, width: coverSize,
height: 100, height: coverSize,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 200, memCacheWidth: (coverSize * 2).round(),
memCacheHeight: 200, memCacheHeight: (coverSize * 2).round(),
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
) )
: Container( : Container(
width: 100, width: coverSize,
height: 100, height: coverSize,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
Icons.music_note, Icons.music_note,
@@ -1090,6 +1240,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
} }
Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) { Widget _buildExploreSection(ExploreSection section, ColorScheme colorScheme) {
final sectionHeight = _exploreSectionHeight(context);
if (section.isYTMusicQuickPicks) { if (section.isYTMusicQuickPicks) {
return _buildYTMusicQuickPicksSection(section, colorScheme); return _buildYTMusicQuickPicksSection(section, colorScheme);
} }
@@ -1107,7 +1258,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
), ),
SizedBox( SizedBox(
height: 175, height: sectionHeight,
child: ListView.builder( child: ListView.builder(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
@@ -1142,11 +1293,13 @@ class _HomeTabState extends ConsumerState<HomeTab>
Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) { Widget _buildExploreItem(ExploreItem item, ColorScheme colorScheme) {
final isArtist = item.type == 'artist'; final isArtist = item.type == 'artist';
final cardSize = _exploreCardSize(context);
final iconSize = cardSize * 0.3;
return GestureDetector( return GestureDetector(
onTap: () => _navigateToExploreItem(item), onTap: () => _navigateToExploreItem(item),
child: SizedBox( child: SizedBox(
width: 120, width: cardSize,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6), padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column( child: Column(
@@ -1155,35 +1308,37 @@ class _HomeTabState extends ConsumerState<HomeTab>
: CrossAxisAlignment.start, : CrossAxisAlignment.start,
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(isArtist ? 60 : 8), borderRadius: BorderRadius.circular(
isArtist ? cardSize / 2 : 8,
),
child: item.coverUrl != null && item.coverUrl!.isNotEmpty child: item.coverUrl != null && item.coverUrl!.isNotEmpty
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: item.coverUrl!, imageUrl: item.coverUrl!,
width: 120, width: cardSize,
height: 120, height: cardSize,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 240, memCacheWidth: (cardSize * 2).round(),
memCacheHeight: 240, memCacheHeight: (cardSize * 2).round(),
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
errorWidget: (context, url, error) => Container( errorWidget: (context, url, error) => Container(
width: 120, width: cardSize,
height: 120, height: cardSize,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
_getIconForType(item.type), _getIconForType(item.type),
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
size: 36, size: iconSize,
), ),
), ),
) )
: Container( : Container(
width: 120, width: cardSize,
height: 120, height: cardSize,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
_getIconForType(item.type), _getIconForType(item.type),
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
size: 36, size: iconSize,
), ),
), ),
), ),
@@ -1394,7 +1549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
duration: item.durationMs ~/ 1000, duration: item.durationMs ~/ 1000,
trackNumber: 1, trackNumber: 1,
discNumber: 1, discNumber: 1,
isrc: item.id, isrc: null,
releaseDate: null, releaseDate: null,
coverUrl: item.coverUrl, coverUrl: item.coverUrl,
source: item.providerId ?? 'spotify-web', source: item.providerId ?? 'spotify-web',
@@ -1484,14 +1639,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (uniqueItems.isEmpty && hasHiddenDownloads) if (uniqueItems.isEmpty)
Center( Padding(
child: Padding( padding: const EdgeInsets.symmetric(vertical: 24),
padding: const EdgeInsets.symmetric(vertical: 24), child: SizedBox(
width: double.infinity,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.visibility_off, hasHiddenDownloads ? Icons.visibility_off : Icons.history,
size: 48, size: 48,
color: colorScheme.onSurfaceVariant.withValues( color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.5, alpha: 0.5,
@@ -1499,21 +1656,24 @@ class _HomeTabState extends ConsumerState<HomeTab>
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'No recent items', context.l10n.recentEmpty,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
const SizedBox(height: 16), if (hasHiddenDownloads) ...[
OutlinedButton.icon( const SizedBox(height: 16),
onPressed: () { OutlinedButton.icon(
ref onPressed: () {
.read(recentAccessProvider.notifier) ref
.clearHiddenDownloads(); .read(recentAccessProvider.notifier)
}, .clearHiddenDownloads();
icon: const Icon(Icons.visibility, size: 18), },
label: const Text('Show All Downloads'), icon: const Icon(Icons.visibility, size: 18),
), label: Text(context.l10n.recentShowAllDownloads),
),
],
], ],
), ),
), ),
+263 -81
View File
@@ -89,7 +89,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_hasMultipleDiscsCache = _discGroupsCache.length > 1; _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>>{}; final discMap = <int, List<LocalLibraryItem>>{};
for (final track in tracks) { for (final track in tracks) {
final discNumber = track.discNumber ?? 1; final discNumber = track.discNumber ?? 1;
@@ -175,7 +177,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( 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 // Go back if all tracks were deleted
@@ -192,7 +196,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))), SnackBar(
content: Text(context.l10n.snackbarCannotOpenFile(e.toString())),
),
); );
} }
} }
@@ -207,12 +213,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
// Show empty state if no tracks found // Show empty state if no tracks found
if (tracks.isEmpty) { if (tracks.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(widget.albumName)),
title: Text(widget.albumName), body: const Center(child: Text('No tracks found for this album')),
),
body: const Center(
child: Text('No tracks found for this album'),
),
); );
} }
@@ -241,7 +243,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_buildInfoCard(context, colorScheme, tracks), _buildInfoCard(context, colorScheme, tracks),
_buildTrackListHeader(context, colorScheme, tracks), _buildTrackListHeader(context, colorScheme, tracks),
_buildTrackList(context, colorScheme, tracks), _buildTrackList(context, colorScheme, tracks),
SliverToBoxAdapter(child: SizedBox(height: _isSelectionMode ? 120 : 32)), SliverToBoxAdapter(
child: SizedBox(height: _isSelectionMode ? 120 : 32),
),
], ],
), ),
@@ -251,7 +255,12 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
left: 0, left: 0,
right: 0, right: 0,
bottom: _isSelectionMode ? 0 : -(200 + bottomPadding), 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) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; 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( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -285,7 +300,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
@@ -298,24 +315,33 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Image.file( Image.file(
File(widget.coverPath!), File(widget.coverPath!),
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => Container(color: colorScheme.surface), errorBuilder: (_, _, _) =>
Container(color: colorScheme.surface),
) )
else else
Container(color: colorScheme.surface), Container(color: colorScheme.surface),
ClipRect( ClipRect(
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), 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( Positioned(
left: 0, right: 0, bottom: 0, height: 80, left: 0,
right: 0,
bottom: 0,
height: bottomGradientHeight,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, 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, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -349,13 +375,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
cacheWidth: (coverSize * 2).toInt(), cacheWidth: (coverSize * 2).toInt(),
errorBuilder: (context, error, stackTrace) => errorBuilder: (context, error, stackTrace) =>
Container( Container(
color: colorScheme.surfaceContainerHighest, color:
child: Icon(Icons.album, size: 64, color: colorScheme.onSurfaceVariant), colorScheme.surfaceContainerHighest,
child: Icon(
Icons.album,
size: fallbackIconSize,
color: colorScheme.onSurfaceVariant,
),
), ),
) )
: Container( : Container(
color: colorScheme.surfaceContainerHighest, 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( leading: IconButton(
icon: Container( icon: Container(
padding: const EdgeInsets.all(8), 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), child: Icon(Icons.arrow_back, color: colorScheme.onSurface),
), ),
onPressed: () => Navigator.pop(context), 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( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Card( child: Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
@@ -394,40 +441,79 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [ children: [
Text( Text(
widget.albumName, 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), const SizedBox(height: 4),
Text( Text(
widget.artistName, 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), const SizedBox(height: 12),
Row( Row(
children: [ children: [
// "Local" badge // "Local" badge
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.folder, size: 14, color: colorScheme.onTertiaryContainer), Icon(
Icons.folder,
size: 14,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4), 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), const SizedBox(width: 8),
// Track count // Track count
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20)), horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.music_note, size: 14, color: colorScheme.onSurfaceVariant), Icon(
Icons.music_note,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4), 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,7 +521,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
// Quality badge if all tracks have the same quality // Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null) if (_getCommonQuality(tracks) != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24') color: _getCommonQuality(tracks)!.contains('24')
? colorScheme.primaryContainer ? colorScheme.primaryContainer
@@ -468,16 +557,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final first = tracks.first; final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null; 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) { 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 null;
} }
} }
return firstQuality; return firstQuality;
} }
Widget _buildTrackListHeader(BuildContext context, ColorScheme colorScheme, List<LocalLibraryItem> tracks) { Widget _buildTrackListHeader(
BuildContext context,
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
return SliverToBoxAdapter( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
@@ -485,14 +580,24 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
children: [ children: [
Icon(Icons.queue_music, size: 20, color: colorScheme.primary), Icon(Icons.queue_music, size: 20, color: colorScheme.primary),
const SizedBox(width: 8), 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(), const Spacer(),
if (!_isSelectionMode) if (!_isSelectionMode)
TextButton.icon( 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), icon: const Icon(Icons.checklist, size: 18),
label: Text(context.l10n.actionSelect), label: Text(context.l10n.actionSelect),
style: TextButton.styleFrom(visualDensity: VisualDensity.compact), style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
),
), ),
], ],
), ),
@@ -500,7 +605,11 @@ 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 discGroups = _discGroupsCache;
final hasMultipleDiscs = _hasMultipleDiscsCache; final hasMultipleDiscs = _hasMultipleDiscsCache;
@@ -517,7 +626,10 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.secondaryContainer, color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -525,14 +637,19 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.album, size: 16, color: colorScheme.onSecondaryContainer), Icon(
Icons.album,
size: 16,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
context.l10n.downloadedAlbumDiscHeader(discNumber), context.l10n.downloadedAlbumDiscHeader(discNumber),
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: Theme.of(context).textTheme.labelLarge
color: colorScheme.onSecondaryContainer, ?.copyWith(
fontWeight: FontWeight.w600, color: colorScheme.onSecondaryContainer,
), fontWeight: FontWeight.w600,
),
), ),
], ],
), ),
@@ -554,7 +671,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
slivers.add( slivers.add(
SliverList( SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(context, index) => _buildTrackItem(context, colorScheme, discTracks[index]), (context, index) =>
_buildTrackItem(context, colorScheme, discTracks[index]),
childCount: discTracks.length, childCount: discTracks.length,
), ),
), ),
@@ -564,21 +682,31 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return SliverMainAxisGroup(slivers: slivers); 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); final isSelected = _selectedIds.contains(track.id);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Card( child: Card(
elevation: 0, 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), margin: const EdgeInsets.symmetric(vertical: 2),
child: ListTile( child: ListTile(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onTap: _isSelectionMode onTap: _isSelectionMode
? () => _toggleSelection(track.id) ? () => _toggleSelection(track.id)
: () => _openFile(track.filePath), : () => _openFile(track.filePath),
onLongPress: _isSelectionMode ? null : () => _enterSelectionMode(track.id), onLongPress: _isSelectionMode
? null
: () => _enterSelectionMode(track.id),
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -587,12 +715,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
width: 24, width: 24,
height: 24, height: 24,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? colorScheme.primary : Colors.transparent, color: isSelected
? colorScheme.primary
: Colors.transparent,
shape: BoxShape.circle, 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 child: isSelected
? Icon(Icons.check, color: colorScheme.onPrimary, size: 16) ? Icon(
Icons.check,
color: colorScheme.onPrimary,
size: 16,
)
: null, : null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -614,7 +753,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
track.trackName, track.trackName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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( subtitle: Row(
children: [ children: [
@@ -627,27 +768,45 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
), ),
), ),
if (track.format != null) ...[ if (track.format != null) ...[
Text('', style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12)), Text(
'',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
Text( Text(
track.format!.toUpperCase(), track.format!.toUpperCase(),
style: TextStyle(color: colorScheme.onSurfaceVariant, fontSize: 12), style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
), ),
], ],
], ],
), ),
trailing: _isSelectionMode ? null : IconButton( trailing: _isSelectionMode
onPressed: () => _openFile(track.filePath), ? null
icon: Icon(Icons.play_arrow, color: colorScheme.primary), : IconButton(
style: IconButton.styleFrom( onPressed: () => _openFile(track.filePath),
backgroundColor: colorScheme.primaryContainer.withValues(alpha: 0.3), 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 selectedCount = _selectedIds.length;
final allSelected = selectedCount == tracks.length && tracks.isNotEmpty; final allSelected = selectedCount == tracks.length && tracks.isNotEmpty;
@@ -694,12 +853,18 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
context.l10n.downloadedAlbumSelectedCount(selectedCount), context.l10n.downloadedAlbumSelectedCount(
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), selectedCount,
),
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
), ),
Text( Text(
allSelected ? context.l10n.downloadedAlbumAllSelected : context.l10n.downloadedAlbumTapToSelect, allSelected
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ? 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); _selectAll(tracks);
} }
}, },
icon: Icon(allSelected ? Icons.deselect : Icons.select_all, size: 20), icon: Icon(
label: Text(allSelected ? context.l10n.actionDeselect : context.l10n.actionSelectAll), allSelected ? Icons.deselect : Icons.select_all,
style: TextButton.styleFrom(foregroundColor: colorScheme.primary), size: 20,
),
label: Text(
allSelected
? context.l10n.actionDeselect
: context.l10n.actionSelectAll,
),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
),
), ),
], ],
), ),
@@ -722,7 +896,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
onPressed: selectedCount > 0 ? () => _deleteSelected(tracks) : null, onPressed: selectedCount > 0
? () => _deleteSelected(tracks)
: null,
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
label: Text( label: Text(
selectedCount > 0 selectedCount > 0
@@ -730,10 +906,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
: context.l10n.downloadedAlbumSelectToDelete, : context.l10n.downloadedAlbumSelectToDelete,
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: selectedCount > 0 ? colorScheme.error : colorScheme.surfaceContainerHighest, backgroundColor: selectedCount > 0
foregroundColor: selectedCount > 0 ? colorScheme.onError : colorScheme.onSurfaceVariant, ? colorScheme.error
: colorScheme.surfaceContainerHighest,
foregroundColor: selectedCount > 0
? colorScheme.onError
: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
), ),
), ),
), ),
+23 -1
View File
@@ -390,7 +390,9 @@ class _MainShellState extends ConsumerState<MainShell> {
body: PageView( body: PageView(
controller: _pageController, controller: _pageController,
onPageChanged: _onPageChanged, onPageChanged: _onPageChanged,
physics: const ClampingScrollPhysics(), physics: (_currentIndex == 0 && trackIsShowingRecentAccess)
? const _NoSwipeRightPhysics()
: const ClampingScrollPhysics(),
children: tabs, children: tabs,
), ),
bottomNavigationBar: NavigationBar( 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 { class BouncingIcon extends StatefulWidget {
final Widget child; final Widget child;
const BouncingIcon({super.key, required this.child}); const BouncingIcon({super.key, required this.child});
+14 -7
View File
@@ -145,11 +145,17 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
} }
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) { Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
final screenWidth = MediaQuery.of(context).size.width; final mediaSize = MediaQuery.of(context).size;
final coverSize = screenWidth * 0.5; // 50% of screen width 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( return SliverAppBar(
expandedHeight: 320, expandedHeight: expandedHeight,
pinned: true, pinned: true,
stretch: true, stretch: true,
backgroundColor: backgroundColor:
@@ -172,7 +178,8 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final collapseRatio = final collapseRatio =
(constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); (constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3; final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar( return FlexibleSpaceBar(
@@ -205,7 +212,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
height: 80, height: bottomGradientHeight,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -225,7 +232,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
opacity: showContent ? 1.0 : 0.0, opacity: showContent ? 1.0 : 0.0,
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 60), padding: EdgeInsets.only(top: coverTopPadding),
child: Container( child: Container(
width: coverSize, width: coverSize,
height: coverSize, height: coverSize,
@@ -252,7 +259,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
child: Icon( child: Icon(
Icons.playlist_play, Icons.playlist_play,
size: 64, size: fallbackIconSize,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
), ),
+37 -11
View File
@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/l10n/l10n.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/utils/file_access.dart';
import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/download_item.dart';
import 'package:spotiflac_android/providers/download_queue_provider.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? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg'
String _sortMode = 'latest'; // 'latest', 'oldest', 'a-z', 'z-a' 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 @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -1550,7 +1565,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
settingsProvider.select((s) => s.historyFilterMode), settingsProvider.select((s) => s.historyFilterMode),
); );
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final historyStats = final historyStats =
_historyStatsCache ?? _historyStatsCache ??
@@ -1632,7 +1647,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
), ),
// Search bar - always at top // Search bar - always at top
if (allHistoryItems.isNotEmpty || hasQueueItems) if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@@ -2927,9 +2942,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
item.speedMBps > 0 // When progress is 0 (unknown size, e.g. YouTube tunnel mode),
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' // show bytes downloaded instead of percentage
: '${(item.progress * 100).toStringAsFixed(0)}%', item.progress > 0
? (item.speedMBps > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%')
: (item.bytesReceived > 0
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: (item.speedMBps > 0
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: 'Starting...')),
style: Theme.of(context).textTheme.labelSmall style: Theme.of(context).textTheme.labelSmall
?.copyWith( ?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,
@@ -2963,22 +2986,25 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) { Widget _buildCoverArt(DownloadItem item, ColorScheme colorScheme) {
final coverSize = _queueCoverSize();
final memCacheSize = (coverSize * 2).round();
return item.track.coverUrl != null return item.track.coverUrl != null
? ClipRRect( ? ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: item.track.coverUrl!, imageUrl: item.track.coverUrl!,
width: 56, width: coverSize,
height: 56, height: coverSize,
fit: BoxFit.cover, fit: BoxFit.cover,
memCacheWidth: 112, memCacheWidth: memCacheSize,
memCacheHeight: 112, memCacheHeight: memCacheSize,
cacheManager: CoverCacheManager.instance, cacheManager: CoverCacheManager.instance,
), ),
) )
: Container( : Container(
width: 56, width: coverSize,
height: 56, height: coverSize,
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
+308 -274
View File
@@ -4,6 +4,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/l10n/l10n.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'; import 'package:spotiflac_android/widgets/settings_group.dart';
class AboutPage extends StatelessWidget { class AboutPage extends StatelessWidget {
@@ -12,7 +13,7 @@ class AboutPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, canPop: true,
@@ -20,218 +21,229 @@ class AboutPage extends StatelessWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 120 + topPadding, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final maxHeight = 120 + topPadding; final maxHeight = 120 + topPadding;
final minHeight = kToolbarHeight + topPadding; final minHeight = kToolbarHeight + topPadding;
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); final expandRatio =
final leftPadding = 56 - (32 * expandRatio); ((constraints.maxHeight - minHeight) /
return FlexibleSpaceBar( (maxHeight - minHeight))
expandedTitleScale: 1.0, .clamp(0.0, 1.0);
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), final leftPadding = 56 - (32 * expandRatio);
title: Text( return FlexibleSpaceBar(
context.l10n.aboutTitle, expandedTitleScale: 1.0,
style: TextStyle( titlePadding: EdgeInsets.only(
fontSize: 20 + (8 * expandRatio), // 20 -> 28 left: leftPadding,
fontWeight: FontWeight.bold, bottom: 16,
color: colorScheme.onSurface,
), ),
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( SliverToBoxAdapter(
child: Padding( child: SettingsSectionHeader(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), title: context.l10n.aboutTranslators,
child: _AppHeaderCard(), ),
), ),
), const SliverToBoxAdapter(child: _TranslatorsSection()),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutContributors), child: SettingsSectionHeader(
), title: context.l10n.aboutSpecialThanks,
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: SettingsGroup(
SliverToBoxAdapter( children: [
child: SettingsSectionHeader(title: context.l10n.aboutTranslators), _ContributorItem(
), name: 'binimum',
const SliverToBoxAdapter( description: context.l10n.aboutBinimumDesc,
child: _TranslatorsSection(), githubUsername: 'binimum',
), showDivider: true,
),
SliverToBoxAdapter( _ContributorItem(
child: SettingsSectionHeader(title: context.l10n.aboutSpecialThanks), name: 'sachinsenal0x64',
), description: context.l10n.aboutSachinsenalDesc,
SliverToBoxAdapter( githubUsername: 'sachinsenal0x64',
child: SettingsGroup( showDivider: true,
children: [ ),
_ContributorItem( _ContributorItem(
name: 'binimum', name: 'sjdonado',
description: context.l10n.aboutBinimumDesc, description: context.l10n.aboutSjdonadoDesc,
githubUsername: 'binimum', githubUsername: 'sjdonado',
showDivider: true, showDivider: true,
), ),
_ContributorItem( _AboutSettingsItem(
name: 'sachinsenal0x64', icon: Icons.music_note_outlined,
description: context.l10n.aboutSachinsenalDesc, title: context.l10n.aboutDabMusic,
githubUsername: 'sachinsenal0x64', subtitle: context.l10n.aboutDabMusicDesc,
showDivider: true, onTap: () => _launchUrl('https://dabmusic.xyz'),
), showDivider: true,
_ContributorItem( ),
name: 'sjdonado', _AboutSettingsItem(
description: context.l10n.aboutSjdonadoDesc, icon: Icons.music_note_outlined,
githubUsername: 'sjdonado', title: context.l10n.aboutSpotiSaver,
showDivider: true, subtitle: context.l10n.aboutSpotiSaverDesc,
), onTap: () => _launchUrl('https://spotisaver.net'),
_AboutSettingsItem( showDivider: false,
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( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutLinks), 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: SettingsGroup(
SliverToBoxAdapter( children: [
child: SettingsSectionHeader(title: context.l10n.aboutSocial), _AboutSettingsItem(
), icon: Icons.phone_android,
SliverToBoxAdapter( title: context.l10n.aboutMobileSource,
child: SettingsGroup( subtitle: 'github.com/${AppInfo.githubRepo}',
children: [ onTap: () => _launchUrl(AppInfo.githubUrl),
_AboutSettingsItem( showDivider: true,
icon: Icons.telegram, ),
title: context.l10n.aboutTelegramChannel, _AboutSettingsItem(
subtitle: context.l10n.aboutTelegramChannelSubtitle, icon: Icons.computer,
onTap: () => _launchUrl('https://t.me/spotiflac'), title: context.l10n.aboutPCSource,
showDivider: true, subtitle: 'github.com/${AppInfo.originalAuthor}/SpotiFLAC',
), onTap: () => _launchUrl(AppInfo.originalGithubUrl),
_AboutSettingsItem( showDivider: true,
icon: Icons.forum_outlined, ),
title: context.l10n.aboutTelegramChat, _AboutSettingsItem(
subtitle: context.l10n.aboutTelegramChatSubtitle, icon: Icons.bug_report_outlined,
onTap: () => _launchUrl('https://t.me/spotiflac_chat'), title: context.l10n.aboutReportIssue,
showDivider: false, 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( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.aboutApp), child: SettingsSectionHeader(title: context.l10n.aboutSocial),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
_AboutSettingsItem( _AboutSettingsItem(
icon: Icons.info_outline, icon: Icons.telegram,
title: context.l10n.aboutVersion, title: context.l10n.aboutTelegramChannel,
subtitle: 'v${AppInfo.version} (build ${AppInfo.buildNumber})', subtitle: context.l10n.aboutTelegramChannelSubtitle,
showDivider: false, 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( SliverToBoxAdapter(
child: Padding( child: SettingsSectionHeader(title: context.l10n.aboutApp),
padding: const EdgeInsets.all(24), ),
child: Center( SliverToBoxAdapter(
child: Text( child: SettingsGroup(
AppInfo.copyright, children: [
style: Theme.of(context).textTheme.bodySmall?.copyWith( _AboutSettingsItem(
color: colorScheme.onSurfaceVariant, 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)),
], ],
),
), ),
),
); );
} }
@@ -248,71 +260,91 @@ class _AppHeaderCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest; : colorScheme.surfaceContainerHighest;
return Container( return LayoutBuilder(
decoration: BoxDecoration( builder: (context, constraints) {
color: cardColor, final cardWidth = constraints.maxWidth;
borderRadius: BorderRadius.circular(20), final shortestSide = MediaQuery.sizeOf(context).shortestSide;
), final textScale = MediaQuery.textScalerOf(
padding: const EdgeInsets.all(24), context,
child: Column( ).scale(1.0).clamp(1.0, 1.4);
children: [ final logoSize = (shortestSide * 0.22).clamp(72.0, 88.0);
Container( final contentPadding = (cardWidth * 0.06).clamp(16.0, 24.0);
width: 88, final titleGap = (16 * (1 + ((textScale - 1) * 0.2))).clamp(12.0, 20.0);
height: 88,
decoration: BoxDecoration( return Container(
color: colorScheme.primary, decoration: BoxDecoration(
shape: BoxShape.circle, color: cardColor,
), borderRadius: BorderRadius.circular(20),
child: Image.asset( ),
'assets/images/logo-transparant.png', padding: EdgeInsets.all(contentPadding),
color: colorScheme.onPrimary, child: Column(
fit: BoxFit.contain, children: [
errorBuilder: (_, _, _) => ClipRRect( Container(
borderRadius: BorderRadius.circular(24), width: logoSize,
height: logoSize,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
),
child: Image.asset( child: Image.asset(
'assets/images/logo.png', 'assets/images/logo-transparant.png',
width: 88, color: colorScheme.onPrimary,
height: 88, fit: BoxFit.contain,
fit: BoxFit.cover, errorBuilder: (_, _, _) => ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Image.asset(
'assets/images/logo.png',
width: logoSize,
height: logoSize,
fit: BoxFit.cover,
),
),
), ),
), ),
), SizedBox(height: titleGap),
), Text(
const SizedBox(height: 16), AppInfo.appName,
Text( textAlign: TextAlign.center,
AppInfo.appName, style: Theme.of(context).textTheme.headlineSmall?.copyWith(
style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold,
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,
), ),
), 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,
),
),
],
),
); );
} }
} }
@@ -347,7 +379,7 @@ class _ContributorItem extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: CachedNetworkImage( child: CachedNetworkImage(
imageUrl: 'https://github.com/$githubUsername.png', imageUrl: 'https://github.com/$githubUsername.png',
width: 40, width: 40,
height: 40, height: 40,
@@ -380,10 +412,7 @@ child: CachedNetworkImage(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(name, style: Theme.of(context).textTheme.bodyLarge),
name,
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
description, description,
@@ -487,7 +516,10 @@ class _TranslatorsSection extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final cardColor = isDark final cardColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.08), colorScheme.surface) ? Color.alphaBlend(
Colors.white.withValues(alpha: 0.08),
colorScheme.surface,
)
: colorScheme.surfaceContainerHighest; : colorScheme.surfaceContainerHighest;
return Padding( return Padding(
@@ -501,9 +533,9 @@ class _TranslatorsSection extends StatelessWidget {
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: _translators.map((translator) => _TranslatorChip( children: _translators
translator: translator, .map((translator) => _TranslatorChip(translator: translator))
)).toList(), .toList(),
), ),
), ),
); );
@@ -535,7 +567,9 @@ class _TranslatorChip extends StatelessWidget {
radius: 10, radius: 10,
backgroundColor: colorScheme.primary.withValues(alpha: 0.2), backgroundColor: colorScheme.primary.withValues(alpha: 0.2),
child: Text( child: Text(
translator.name.isNotEmpty ? translator.name[0].toUpperCase() : '?', translator.name.isNotEmpty
? translator.name[0].toUpperCase()
: '?',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 10,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -552,10 +586,7 @@ class _TranslatorChip extends StatelessWidget {
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(translator.flag, style: const TextStyle(fontSize: 14)),
translator.flag,
style: const TextStyle(fontSize: 14),
),
], ],
), ),
), ),
@@ -602,31 +633,34 @@ class _AboutSettingsItem extends StatelessWidget {
SizedBox( SizedBox(
width: 40, width: 40,
height: 40, height: 40,
child: Icon(icon, color: colorScheme.onSurfaceVariant, size: 24), child: Icon(
icon,
color: colorScheme.onSurfaceVariant,
size: 24,
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(title, style: Theme.of(context).textTheme.bodyLarge),
title,
style: Theme.of(context).textTheme.bodyLarge,
),
if (subtitle != null) ...[ if (subtitle != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
subtitle!, subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
color: colorScheme.onSurfaceVariant, ?.copyWith(color: colorScheme.onSurfaceVariant),
),
), ),
], ],
], ],
), ),
), ),
if (onTap != null) if (onTap != null)
Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant), Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
], ],
), ),
), ),
+171 -161
View File
@@ -4,6 +4,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/l10n/supported_locales.dart'; import 'package:spotiflac_android/l10n/supported_locales.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/theme_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'; import 'package:spotiflac_android/widgets/settings_group.dart';
class AppearanceSettingsPage extends ConsumerWidget { class AppearanceSettingsPage extends ConsumerWidget {
@@ -14,7 +15,7 @@ class AppearanceSettingsPage extends ConsumerWidget {
final themeSettings = ref.watch(themeProvider); final themeSettings = ref.watch(themeProvider);
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, canPop: true,
@@ -22,21 +23,21 @@ class AppearanceSettingsPage extends ConsumerWidget {
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverAppBar(
expandedHeight: 120 + topPadding, expandedHeight: 120 + topPadding,
collapsedHeight: kToolbarHeight, collapsedHeight: kToolbarHeight,
floating: false, floating: false,
pinned: true, pinned: true,
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
), ),
flexibleSpace: _AppBarTitle(
title: context.l10n.appearanceTitle,
topPadding: topPadding,
),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
@@ -77,8 +78,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
onColorSelected: (color) => onColorSelected: (color) =>
ref.read(themeProvider.notifier).setSeedColor(color), ref.read(themeProvider.notifier).setSeedColor(color),
), ),
),
), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SettingsSectionHeader(title: context.l10n.sectionTheme), child: SettingsSectionHeader(title: context.l10n.sectionTheme),
@@ -113,9 +114,8 @@ class AppearanceSettingsPage extends ConsumerWidget {
children: [ children: [
_LanguageSelector( _LanguageSelector(
currentLocale: settings.locale, currentLocale: settings.locale,
onChanged: (locale) => ref onChanged: (locale) =>
.read(settingsProvider.notifier) ref.read(settingsProvider.notifier).setLocale(locale),
.setLocale(locale),
), ),
], ],
), ),
@@ -156,151 +156,167 @@ class _ThemePreviewCard extends StatelessWidget {
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
return RepaintBoundary( return RepaintBoundary(
child: Container( child: LayoutBuilder(
height: 200, builder: (context, constraints) {
width: double.infinity, final cardWidth = constraints.maxWidth;
decoration: BoxDecoration( final previewHeight = (cardWidth * 0.56).clamp(170.0, 220.0);
color: colorScheme final innerWidth = (cardWidth - 48).clamp(220.0, 320.0);
.surfaceContainerHighest, final innerHeight = (previewHeight * 0.70).clamp(120.0, 160.0);
borderRadius: BorderRadius.circular(28), final innerPadding = (innerHeight * 0.11).clamp(12.0, 18.0);
), final artworkSize = (innerHeight - (innerPadding * 2)).clamp(
clipBehavior: Clip.antiAlias, 80.0,
child: Stack( 120.0,
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),
),
),
),
Center( return Container(
child: Container( constraints: BoxConstraints(minHeight: previewHeight),
width: 260, width: double.infinity,
height: 140, decoration: BoxDecoration(
padding: const EdgeInsets.all(16), color: colorScheme.surfaceContainerHighest,
decoration: BoxDecoration( borderRadius: BorderRadius.circular(28),
color: colorScheme.surface, ),
borderRadius: BorderRadius.circular(20), clipBehavior: Clip.antiAlias,
boxShadow: [ child: Stack(
BoxShadow( children: [
color: Colors.black.withValues(alpha: 0.1), Positioned(
blurRadius: 12, top: -(previewHeight * 0.25),
offset: const Offset(0, 8), right: -(previewHeight * 0.25),
), child: Container(
], width: previewHeight,
), height: previewHeight,
child: Row( decoration: BoxDecoration(
children: [ shape: BoxShape.circle,
Container( color: colorScheme.primaryContainer.withValues(
width: 108, alpha: 0.5,
height: 108,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.music_note,
color: colorScheme.onPrimary,
size: 48,
), ),
), ),
const SizedBox(width: 16), ),
),
Expanded( Positioned(
child: Column( bottom: -(previewHeight * 0.15),
crossAxisAlignment: CrossAxisAlignment.start, left: -(previewHeight * 0.15),
mainAxisAlignment: MainAxisAlignment.center, child: Container(
children: [ width: previewHeight * 0.75,
Container( height: previewHeight * 0.75,
width: double.infinity, decoration: BoxDecoration(
height: 14, shape: BoxShape.circle,
decoration: BoxDecoration( color: colorScheme.tertiaryContainer.withValues(
color: colorScheme.onSurface, alpha: 0.5,
borderRadius: BorderRadius.circular(4), ),
), ),
),
),
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), child: Icon(
Container( Icons.music_note,
width: 80, color: colorScheme.onPrimary,
height: 10, size: artworkSize * 0.44,
decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(height: 24), ),
Row( const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Container(
Icons.skip_previous, width: double.infinity,
size: 24, height: 14,
color: colorScheme.onSurfaceVariant, decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(width: 12), const SizedBox(height: 8),
Icon( Container(
Icons.play_circle_fill, width: 80,
size: 32, height: 10,
color: colorScheme.primary, decoration: BoxDecoration(
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
), ),
const SizedBox(width: 12), const SizedBox(height: 24),
Icon( Row(
Icons.skip_next, children: [
size: 24, Icon(
color: colorScheme.onSurfaceVariant, 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,
),
),
),
),
],
), ),
], );
), },
), ),
); );
} }
@@ -694,7 +710,7 @@ class _LanguageSelector extends StatelessWidget {
required this.onChanged, required this.onChanged,
}); });
static const _allLanguages = [ static const _allLanguages = [
('system', 'System Default', Icons.phone_android), ('system', 'System Default', Icons.phone_android),
('en', 'English', Icons.language), ('en', 'English', Icons.language),
('id', 'Bahasa Indonesia', Icons.language), ('id', 'Bahasa Indonesia', Icons.language),
@@ -735,16 +751,10 @@ static const _allLanguages = [
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return ListTile( return ListTile(
leading: Icon( leading: Icon(Icons.language, color: colorScheme.onSurfaceVariant),
Icons.language,
color: colorScheme.onSurfaceVariant,
),
title: Text(context.l10n.appearanceLanguage), title: Text(context.l10n.appearanceLanguage),
subtitle: Text(_getLanguageName(currentLocale)), subtitle: Text(_getLanguageName(currentLocale)),
trailing: Icon( trailing: Icon(Icons.chevron_right, color: colorScheme.onSurfaceVariant),
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
onTap: () => _showLanguagePicker(context), onTap: () => _showLanguagePicker(context),
); );
} }
@@ -765,9 +775,9 @@ static const _allLanguages = [
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Text( child: Text(
context.l10n.appearanceLanguage, context.l10n.appearanceLanguage,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
@@ -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,
});
}
+4 -1
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:spotiflac_android/constants/app_info.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'; import 'package:spotiflac_android/widgets/donate_icons.dart';
class DonatePage extends StatelessWidget { class DonatePage extends StatelessWidget {
@@ -9,7 +10,7 @@ class DonatePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
@@ -199,6 +200,8 @@ class _RecentDonorsCard extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_DonorTile(name: 'J', colorScheme: colorScheme),
_DonorTile(name: 'Julian', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme), _DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile( _DonorTile(
name: '283Fabio', name: '283Fabio',
@@ -9,6 +9,8 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.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/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class DownloadSettingsPage extends ConsumerStatefulWidget { class DownloadSettingsPage extends ConsumerStatefulWidget {
@@ -20,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
} }
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> { class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz', 'amazon']; static const _builtInServices = ['tidal', 'qobuz'];
int _androidSdkVersion = 0; int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false; bool _hasAllFilesAccess = false;
@@ -93,7 +95,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService); final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal'; final isTidalService = settings.defaultService == 'tidal';
@@ -246,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Select Tidal, Qobuz, or Amazon above to configure quality', 'Select Tidal or Qobuz above to configure quality',
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -346,7 +348,22 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
ref, ref,
settings.folderOrganization, 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,
), ),
], ],
), ),
@@ -901,17 +918,14 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
// Note: iOS requires folder to have at least one file to be selectable // Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) { if (result != null) {
// iOS: Check if user selected iCloud Drive (not accessible by Go backend) // iOS: Validate the selected path is writable (not iCloud or container root)
if (Platform.isIOS) { if (Platform.isIOS) {
final isICloudPath = final validation = validateIosPath(result);
result.contains('Mobile Documents') || if (!validation.isValid) {
result.contains('CloudDocs') ||
result.contains('com~apple~CloudDocs');
if (isICloudPath) {
if (ctx.mounted) { if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.setupIcloudNotSupported), content: Text(validation.errorReason ?? context.l10n.setupIcloudNotSupported),
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
@@ -1340,7 +1354,6 @@ class _ServiceSelector extends ConsumerWidget {
final isExtensionService = ![ final isExtensionService = ![
'tidal', 'tidal',
'qobuz', 'qobuz',
'amazon',
].contains(currentService); ].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService) ? extensionProviders.any((e) => e.id == currentService)
@@ -1367,15 +1380,6 @@ class _ServiceSelector extends ConsumerWidget {
isSelected: effectiveService == 'qobuz', isSelected: effectiveService == 'qobuz',
onTap: () => onChanged('qobuz'), onTap: () => onChanged('qobuz'),
), ),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
isDisabled: true,
disabledReason: 'Coming soon',
onTap: () {},
),
], ],
), ),
if (extensionProviders.isNotEmpty) ...[ if (extensionProviders.isNotEmpty) ...[
@@ -1411,15 +1415,11 @@ class _ServiceChip extends StatelessWidget {
final String label; final String label;
final bool isSelected; final bool isSelected;
final VoidCallback onTap; final VoidCallback onTap;
final bool isDisabled;
final String? disabledReason;
const _ServiceChip({ const _ServiceChip({
required this.icon, required this.icon,
required this.label, required this.label,
required this.isSelected, required this.isSelected,
required this.onTap, required this.onTap,
this.isDisabled = false,
this.disabledReason,
}); });
@override @override
@@ -1434,66 +1434,39 @@ class _ServiceChip extends StatelessWidget {
) )
: colorScheme.surfaceContainerHigh; : colorScheme.surfaceContainerHigh;
final disabledColor = isDark
? Color.alphaBlend(
Colors.white.withValues(alpha: 0.02),
colorScheme.surface,
)
: colorScheme.surfaceContainerLow;
return Expanded( return Expanded(
child: Tooltip( child: Material(
message: isDisabled && disabledReason != null ? disabledReason! : '', color: isSelected
child: Material( ? colorScheme.primaryContainer
color: isDisabled : unselectedColor,
? disabledColor borderRadius: BorderRadius.circular(12),
: isSelected child: InkWell(
? colorScheme.primaryContainer onTap: onTap,
: unselectedColor,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: InkWell( child: Padding(
onTap: isDisabled ? null : onTap, padding: const EdgeInsets.symmetric(vertical: 14),
borderRadius: BorderRadius.circular(12), child: Column(
child: Padding( children: [
padding: const EdgeInsets.symmetric(vertical: 14), Icon(
child: Column( icon,
children: [ color: isSelected
Icon( ? colorScheme.onPrimaryContainer
icon, : colorScheme.onSurfaceVariant,
color: isDisabled ),
? colorScheme.onSurface.withValues(alpha: 0.38) const SizedBox(height: 6),
: isSelected Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.normal,
color: isSelected
? colorScheme.onPrimaryContainer ? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
), ),
const SizedBox(height: 6), ),
Text( ],
label,
style: TextStyle(
fontSize: 12,
fontWeight: isSelected && !isDisabled
? FontWeight.w600
: FontWeight.normal,
color: isDisabled
? colorScheme.onSurface.withValues(alpha: 0.38)
: isSelected
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
),
),
if (isDisabled && disabledReason != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
disabledReason!,
style: TextStyle(
fontSize: 9,
color: colorScheme.onSurface.withValues(alpha: 0.38),
),
),
),
],
),
), ),
), ),
), ),
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.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'; import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionDetailPage extends ConsumerStatefulWidget { class ExtensionDetailPage extends ConsumerStatefulWidget {
@@ -55,7 +56,7 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
); );
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final hasError = extension.status == 'error'; final hasError = extension.status == 'error';
return PopScope( return PopScope(
+2 -1
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/extension_detail_page.dart'; import 'package:spotiflac_android/screens/settings/extension_detail_page.dart';
import 'package:spotiflac_android/screens/settings/provider_priority_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/screens/settings/metadata_provider_priority_page.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
class ExtensionsPage extends ConsumerStatefulWidget { class ExtensionsPage extends ConsumerStatefulWidget {
@@ -51,7 +52,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final extState = ref.watch(extensionProvider); final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture canPop: true, // Always allow back gesture
+57 -23
View File
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.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'; import 'package:spotiflac_android/widgets/settings_group.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget { class LibrarySettingsPage extends ConsumerStatefulWidget {
@@ -30,7 +31,8 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
// -> /storage/emulated/0/Music // -> /storage/emulated/0/Music
try { try {
final uri = Uri.parse(path); 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); final decoded = Uri.decodeComponent(treePath);
if (decoded.startsWith('primary:')) { if (decoded.startsWith('primary:')) {
return '/storage/emulated/0/${decoded.substring('primary:'.length)}'; return '/storage/emulated/0/${decoded.substring('primary:'.length)}';
@@ -156,10 +158,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
return; return;
} }
await ref.read(localLibraryProvider.notifier).startScan( await ref
libraryPath, .read(localLibraryProvider.notifier)
forceFullScan: forceFullScan, .startScan(libraryPath, forceFullScan: forceFullScan);
);
} }
Future<void> _cancelScan() async { Future<void> _cancelScan() async {
@@ -216,7 +217,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
final settings = ref.watch(settingsProvider); final settings = ref.watch(settingsProvider);
final libraryState = ref.watch(localLibraryProvider); final libraryState = ref.watch(localLibraryProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: CustomScrollView( body: CustomScrollView(
@@ -260,6 +261,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
SliverToBoxAdapter( SliverToBoxAdapter(
child: _LibraryHeroCard( child: _LibraryHeroCard(
itemCount: libraryState.items.length, itemCount: libraryState.items.length,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning, isScanning: libraryState.isScanning,
scanProgress: libraryState.scanProgress, scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile, scanCurrentFile: libraryState.scanCurrentFile,
@@ -331,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
child: Container( child: Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.6), color: colorScheme.tertiaryContainer.withValues(
alpha: 0.6,
),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -347,17 +351,20 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
children: [ children: [
Text( Text(
'Scan cancelled', 'Scan cancelled',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium
fontWeight: FontWeight.w600, ?.copyWith(
color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600,
), color: colorScheme.onTertiaryContainer,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
'You can retry the scan when ready.', 'You can retry the scan when ready.',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall
color: colorScheme.onTertiaryContainer.withValues(alpha: 0.8), ?.copyWith(
), color: colorScheme.onTertiaryContainer
.withValues(alpha: 0.8),
),
), ),
], ],
), ),
@@ -493,6 +500,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
class _LibraryHeroCard extends StatelessWidget { class _LibraryHeroCard extends StatelessWidget {
final int itemCount; final int itemCount;
final int excludedDownloadedCount;
final bool isScanning; final bool isScanning;
final double scanProgress; final double scanProgress;
final String? scanCurrentFile; final String? scanCurrentFile;
@@ -502,6 +510,7 @@ class _LibraryHeroCard extends StatelessWidget {
const _LibraryHeroCard({ const _LibraryHeroCard({
required this.itemCount, required this.itemCount,
required this.excludedDownloadedCount,
required this.isScanning, required this.isScanning,
required this.scanProgress, required this.scanProgress,
this.scanCurrentFile, this.scanCurrentFile,
@@ -527,10 +536,13 @@ class _LibraryHeroCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark; final isDark = Theme.of(context).brightness == Brightness.dark;
final displayCount = isScanning
? scannedFiles
: itemCount + excludedDownloadedCount;
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
height: 220, constraints: const BoxConstraints(minHeight: 220),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
@@ -626,12 +638,12 @@ class _LibraryHeroCard extends StatelessWidget {
), ),
], ],
), ),
const Spacer(), const SizedBox(height: 16),
FittedBox( FittedBox(
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(
isScanning ? scannedFiles.toString() : itemCount.toString(), displayCount.toString(),
style: TextStyle( style: TextStyle(
fontSize: 48, fontSize: 48,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -644,17 +656,35 @@ class _LibraryHeroCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
isScanning isScanning
? context.l10n.libraryTracksCount(scannedFiles).replaceAll(scannedFiles.toString(), '').trim() ? context.l10n
.libraryTracksCount(scannedFiles)
.replaceAll(scannedFiles.toString(), '')
.trim()
: context.l10n : context.l10n
.libraryTracksCount(itemCount) .libraryTracksCount(displayCount)
.replaceAll(itemCount.toString(), '') .replaceAll(displayCount.toString(), '')
.trim(), .trim(),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w500, 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) ...[ if (isScanning && scanCurrentFile != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
LinearProgressIndicator( LinearProgressIndicator(
@@ -670,7 +700,9 @@ class _LibraryHeroCard extends StatelessWidget {
Icon( Icon(
Icons.history, Icons.history,
size: 14, size: 14,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
@@ -679,7 +711,9 @@ class _LibraryHeroCard extends StatelessWidget {
), ),
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
), ),
), ),
], ],
+2 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus; import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
import 'package:spotiflac_android/l10n/l10n.dart'; 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/utils/logger.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -126,7 +127,7 @@ class _LogScreenState extends State<LogScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
final logs = _filteredLogs; final logs = _filteredLogs;
return PopScope( return PopScope(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget { class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
const MetadataProviderPriorityPage({super.key}); const MetadataProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _MetadataProviderPriorityPageState extends ConsumerState<MetadataProviderP
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: !_hasChanges, 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/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_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'; import 'package:spotiflac_android/widgets/settings_group.dart';
class OptionsSettingsPage extends ConsumerWidget { class OptionsSettingsPage extends ConsumerWidget {
@@ -16,7 +17,7 @@ class OptionsSettingsPage extends ConsumerWidget {
final extensionState = ref.watch(extensionProvider); final extensionState = ref.watch(extensionProvider);
final hasExtensions = extensionState.extensions.isNotEmpty; final hasExtensions = extensionState.extensions.isNotEmpty;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: true, // Always allow back gesture 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget { class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key}); const ProviderPriorityPage({super.key});
@@ -40,7 +41,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return PopScope( return PopScope(
canPop: !_hasChanges, canPop: !_hasChanges,
+20 -7
View File
@@ -8,8 +8,10 @@ import 'package:spotiflac_android/screens/settings/extensions_page.dart';
import 'package:spotiflac_android/screens/settings/library_settings_page.dart'; import 'package:spotiflac_android/screens/settings/library_settings_page.dart';
import 'package:spotiflac_android/screens/settings/options_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/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/donate_page.dart';
import 'package:spotiflac_android/screens/settings/log_screen.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'; import 'package:spotiflac_android/widgets/settings_group.dart';
class SettingsTab extends ConsumerWidget { class SettingsTab extends ConsumerWidget {
@@ -18,7 +20,7 @@ class SettingsTab extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
@@ -73,19 +75,29 @@ class SettingsTab extends ConsumerWidget {
icon: Icons.download_outlined, icon: Icons.download_outlined,
title: l10n.settingsDownload, title: l10n.settingsDownload,
subtitle: l10n.settingsDownloadSubtitle, subtitle: l10n.settingsDownloadSubtitle,
onTap: () => _navigateTo(context, const DownloadSettingsPage()), onTap: () =>
_navigateTo(context, const DownloadSettingsPage()),
), ),
SettingsItem( SettingsItem(
icon: Icons.library_music_outlined, icon: Icons.library_music_outlined,
title: l10n.settingsLocalLibrary, title: l10n.settingsLocalLibrary,
subtitle: l10n.settingsLocalLibrarySubtitle, 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( SettingsItem(
icon: Icons.tune_outlined, icon: Icons.tune_outlined,
title: l10n.settingsOptions, title: l10n.settingsOptions,
subtitle: l10n.settingsOptionsSubtitle, subtitle: l10n.settingsOptionsSubtitle,
onTap: () => _navigateTo(context, const OptionsSettingsPage()), onTap: () =>
_navigateTo(context, const OptionsSettingsPage()),
), ),
SettingsItem( SettingsItem(
icon: Icons.extension_outlined, icon: Icons.extension_outlined,
@@ -146,9 +158,10 @@ class SettingsTab extends ConsumerWidget {
const begin = Offset(1.0, 0.0); const begin = Offset(1.0, 0.0);
const end = Offset.zero; const end = Offset.zero;
const curve = Curves.easeInOut; const curve = Curves.easeInOut;
var tween = Tween(begin: begin, end: end).chain( var tween = Tween(
CurveTween(curve: curve), begin: begin,
); end: end,
).chain(CurveTween(curve: curve));
return SlideTransition( return SlideTransition(
position: animation.drive(tween), position: animation.drive(tween),
child: child, child: child,
+132 -69
View File
@@ -9,6 +9,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
class SetupScreen extends ConsumerStatefulWidget { class SetupScreen extends ConsumerStatefulWidget {
const SetupScreen({super.key}); const SetupScreen({super.key});
@@ -248,7 +249,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(context.l10n.setupUseDefaultFolder), title: Text(context.l10n.setupUseDefaultFolder),
content: Text('${context.l10n.setupNoFolderSelected}\n\n$defaultDir'), content: Text(
'${context.l10n.setupNoFolderSelected}\n\n$defaultDir',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(context, false),
@@ -320,6 +323,22 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
Navigator.pop(ctx); Navigator.pop(ctx);
final result = await FilePicker.platform.getDirectoryPath(); final result = await FilePicker.platform.getDirectoryPath();
if (result != null) { if (result != null) {
// iOS: Validate the selected path is writable
if (Platform.isIOS) {
final validation = validateIosPath(result);
if (!validation.isValid) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(validation.errorReason ?? 'Invalid folder selected'),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
);
}
return;
}
}
setState(() => _selectedDirectory = result); setState(() => _selectedDirectory = result);
} }
}, },
@@ -576,37 +595,60 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
} }
Widget _buildWelcomeStep(ColorScheme colorScheme) { Widget _buildWelcomeStep(ColorScheme colorScheme) {
return Padding( return LayoutBuilder(
padding: const EdgeInsets.all(24), builder: (context, constraints) {
child: Column( final shortestSide = MediaQuery.sizeOf(context).shortestSide;
mainAxisAlignment: MainAxisAlignment.center, final textScale = MediaQuery.textScalerOf(
children: [ context,
Image.asset( ).scale(1.0).clamp(1.0, 1.4);
'assets/images/logo-transparant.png', final logoSize = (shortestSide * 0.24).clamp(80.0, 104.0);
width: 104, final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
height: 104, final subtitleGap = (shortestSide * 0.04).clamp(8.0, 16.0);
color: colorScheme.primary, final minContentHeight = constraints.maxHeight > 48
fit: BoxFit.contain, ? constraints.maxHeight - 48
), : 0.0;
const SizedBox(height: 32),
Text( return SingleChildScrollView(
context.l10n.appName, padding: const EdgeInsets.all(24),
style: Theme.of(context).textTheme.displaySmall?.copyWith( child: ConstrainedBox(
fontWeight: FontWeight.bold, constraints: BoxConstraints(minHeight: minContentHeight),
color: colorScheme.onSurface, 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 +875,58 @@ class _StepLayout extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Padding( return LayoutBuilder(
padding: const EdgeInsets.all(24), builder: (context, constraints) {
child: Column( final shortestSide = MediaQuery.sizeOf(context).shortestSide;
mainAxisAlignment: MainAxisAlignment.center, final iconPadding = (shortestSide * 0.06).clamp(16.0, 24.0);
children: [ final iconSize = (shortestSide * 0.12).clamp(32.0, 48.0);
Container( final titleGap = (shortestSide * 0.06).clamp(16.0, 32.0);
padding: const EdgeInsets.all(24), final descriptionGap = (shortestSide * 0.04).clamp(8.0, 16.0);
decoration: BoxDecoration( final actionGap = (shortestSide * 0.09).clamp(20.0, 48.0);
color: colorScheme.surfaceContainerHighest, final minContentHeight = constraints.maxHeight > 48
shape: BoxShape.circle, ? 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 +940,25 @@ class _SuccessCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
width: double.infinity,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer, color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer), Icon(Icons.check_circle, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 12), const SizedBox(width: 12),
Text( Expanded(
text, child: Text(
style: TextStyle( text,
fontWeight: FontWeight.bold, style: TextStyle(
color: colorScheme.onPrimaryContainer, fontWeight: FontWeight.bold,
color: colorScheme.onPrimaryContainer,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
), ),
), ),
], ],
+2 -1
View File
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; import 'package:spotiflac_android/screens/store/extension_details_screen.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget { class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key}); const StoreTab({super.key});
@@ -44,7 +45,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(storeProvider); final state = ref.watch(storeProvider);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return Scaffold( return Scaffold(
body: RefreshIndicator( body: RefreshIndicator(
+882 -15
View File
@@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/l10n/l10n.dart';
class TrackMetadataScreen extends ConsumerStatefulWidget { class TrackMetadataScreen extends ConsumerStatefulWidget {
@@ -35,6 +36,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bool _lyricsEmbedded = false; // Track if lyrics are embedded in file bool _lyricsEmbedded = false; // Track if lyrics are embedded in file
bool _isEmbedding = false; // Track embed operation in progress bool _isEmbedding = false; // Track embed operation in progress
bool _isInstrumental = false; // Track if detected as instrumental bool _isInstrumental = false; // Track if detected as instrumental
Map<String, dynamic>? _editedMetadata; // Overrides after metadata edit
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
static final RegExp _lrcTimestampPattern = static final RegExp _lrcTimestampPattern =
RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]');
@@ -117,17 +119,35 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
LocalLibraryItem? get _localLibraryItem => widget.localItem; LocalLibraryItem? get _localLibraryItem => widget.localItem;
String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id;
String get trackName => _isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName; String get trackName => _editedMetadata?['title']?.toString() ?? (_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName);
String get artistName => _isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName; String get artistName => _editedMetadata?['artist']?.toString() ?? (_isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName);
String get albumName => _isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName; String get albumName => _editedMetadata?['album']?.toString() ?? (_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName);
String? get albumArtist => _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist); String? get albumArtist {
int? get trackNumber => _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber; final edited = _editedMetadata?['album_artist']?.toString();
int? get discNumber => _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber; if (edited != null && edited.isNotEmpty) return edited;
String? get releaseDate => _isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate; return _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist);
String? get isrc => _isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc; }
String? get genre => _isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre; int? get trackNumber {
String? get label => _isLocalItem ? null : _downloadItem!.label; final edited = _editedMetadata?['track_number'];
String? get copyright => _isLocalItem ? null : _downloadItem!.copyright; if (edited != null) {
final v = int.tryParse(edited.toString());
if (v != null && v > 0) return v;
}
return _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber;
}
int? get discNumber {
final edited = _editedMetadata?['disc_number'];
if (edited != null) {
final v = int.tryParse(edited.toString());
if (v != null && v > 0) return v;
}
return _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber;
}
String? get releaseDate => _editedMetadata?['date']?.toString() ?? (_isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate);
String? get isrc => _editedMetadata?['isrc']?.toString() ?? (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc);
String? get genre => _editedMetadata?['genre']?.toString() ?? (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre);
String? get label => _editedMetadata?['label']?.toString() ?? (_isLocalItem ? null : _downloadItem!.label);
String? get copyright => _editedMetadata?['copyright']?.toString() ?? (_isLocalItem ? null : _downloadItem!.copyright);
int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration; int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth;
int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate;
@@ -1083,6 +1103,380 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} }
String _buildSaveBaseName() {
final artist = artistName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
final track = trackName.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
return '$artist - $track';
}
String _getFileDirectory() {
if (isContentUri(cleanFilePath)) {
// SAF URIs don't have a filesystem parent directory
return '';
}
final file = File(cleanFilePath);
return file.parent.path;
}
bool get _isSafFile => isContentUri(cleanFilePath);
Future<void> _saveCoverArt() async {
try {
final baseName = _buildSaveBaseName();
if (_isSafFile) {
// SAF file: save to temp, then copy to SAF tree
final tempDir = await Directory.systemTemp.createTemp('cover_');
final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.jpg';
Map<String, dynamic> result;
if (_coverUrl != null && _coverUrl!.isNotEmpty) {
result = await PlatformBridge.downloadCoverToFile(
_coverUrl!,
tempOutput,
maxQuality: true,
);
} else if (_fileExists) {
result = await PlatformBridge.extractCoverToFile(
cleanFilePath,
tempOutput,
);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverNoSource)),
);
}
return;
}
if (result['error'] != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
);
}
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
return;
}
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: '$baseName.jpg',
mimeType: 'image/jpeg',
srcPath: tempOutput,
);
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
if (mounted) {
if (safUri != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write to storage'))),
);
}
}
} else {
// No SAF tree info, keep in temp
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('No storage access'))),
);
}
}
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg';
Map<String, dynamic> result;
if (_coverUrl != null && _coverUrl!.isNotEmpty) {
result = await PlatformBridge.downloadCoverToFile(
_coverUrl!,
outputPath,
maxQuality: true,
);
} else if (_fileExists) {
result = await PlatformBridge.extractCoverToFile(
cleanFilePath,
outputPath,
);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverNoSource)),
);
}
return;
}
if (mounted) {
if (result['error'] != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackCoverSaved(baseName))),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
);
}
}
}
Future<void> _saveLyrics() async {
try {
final baseName = _buildSaveBaseName();
final durationMs = (duration ?? 0) * 1000;
if (_isSafFile) {
// SAF file: save to temp, then copy to SAF tree
final tempDir = await Directory.systemTemp.createTemp('lyrics_');
final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.lrc';
final result = await PlatformBridge.fetchAndSaveLyrics(
trackName: trackName,
artistName: artistName,
spotifyId: _spotifyId ?? '',
durationMs: durationMs,
outputPath: tempOutput,
);
if (result['error'] != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
);
}
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
return;
}
// Write temp file to SAF tree
final treeUri = _downloadItem?.downloadTreeUri;
final relativeDir = _downloadItem?.safRelativeDir ?? '';
if (treeUri != null && treeUri.isNotEmpty) {
final safUri = await PlatformBridge.createSafFileFromPath(
treeUri: treeUri,
relativeDir: relativeDir,
fileName: '$baseName.lrc',
mimeType: 'text/plain',
srcPath: tempOutput,
);
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
if (mounted) {
if (safUri != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write to storage'))),
);
}
}
} else {
try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('No storage access'))),
);
}
}
return;
}
// Regular file path
final dir = _getFileDirectory();
final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc';
final result = await PlatformBridge.fetchAndSaveLyrics(
trackName: trackName,
artistName: artistName,
spotifyId: _spotifyId ?? '',
durationMs: durationMs,
outputPath: outputPath,
);
if (mounted) {
if (result['error'] != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
);
}
}
}
Future<void> _reEnrichMetadata() async {
if (!_fileExists) return;
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
);
final durationMs = (duration ?? 0) * 1000;
final request = <String, dynamic>{
'file_path': cleanFilePath,
'cover_url': _coverUrl ?? '',
'max_quality': true,
'embed_lyrics': true,
'spotify_id': _spotifyId ?? '',
'track_name': trackName,
'artist_name': artistName,
'album_name': albumName,
'album_artist': albumArtist ?? artistName,
'track_number': trackNumber ?? 0,
'disc_number': discNumber ?? 0,
'release_date': releaseDate ?? '',
'isrc': isrc ?? '',
'genre': genre ?? '',
'label': label ?? '',
'copyright': copyright ?? '',
'duration_ms': durationMs,
'search_online': true,
};
final result = await PlatformBridge.reEnrichFile(request);
final method = result['method'] as String?;
// Update local UI state with enriched metadata from online search
final enriched = result['enriched_metadata'] as Map<String, dynamic>?;
if (enriched != null && mounted) {
setState(() {
_editedMetadata = {
'title': enriched['track_name'] ?? trackName,
'artist': enriched['artist_name'] ?? artistName,
'album': enriched['album_name'] ?? albumName,
'album_artist': enriched['album_artist'] ?? albumArtist,
'date': enriched['release_date'] ?? releaseDate,
'track_number': enriched['track_number'] ?? trackNumber,
'disc_number': enriched['disc_number'] ?? discNumber,
'isrc': enriched['isrc'] ?? isrc,
'genre': enriched['genre'] ?? genre,
'label': enriched['label'] ?? label,
'copyright': enriched['copyright'] ?? copyright,
};
});
}
if (method == 'native') {
// FLAC - handled natively by Go (SAF write-back handled in Kotlin)
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
);
}
} else if (method == 'ffmpeg') {
// MP3/Opus - need FFmpeg from Dart side
// For SAF files, Kotlin returns temp_path + saf_uri
final tempPath = result['temp_path'] as String?;
final safUri = result['saf_uri'] as String?;
final ffmpegTarget = tempPath ?? cleanFilePath;
final coverPath = result['cover_path'] as String?;
final metadata = (result['metadata'] as Map<String, dynamic>?)
?.map((k, v) => MapEntry(k, v.toString()));
final lower = cleanFilePath.toLowerCase();
String? ffmpegResult;
if (lower.endsWith('.mp3')) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: ffmpegTarget,
coverPath: coverPath,
metadata: metadata,
);
} else if (lower.endsWith('.opus') || lower.endsWith('.ogg')) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: coverPath,
metadata: metadata,
);
}
// For SAF files, copy processed temp file back
if (ffmpegResult != null && tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write back to storage'))),
);
// Cleanup temp files
if (coverPath != null && coverPath.isNotEmpty) {
try { await File(coverPath).delete(); } catch (_) {}
}
if (tempPath.isNotEmpty) {
try { await File(tempPath).delete(); } catch (_) {}
}
return;
}
}
// Cleanup temp files
if (tempPath != null && tempPath.isNotEmpty) {
try { await File(tempPath).delete(); } catch (_) {}
}
if (mounted) {
if (ffmpegResult != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichFfmpegFailed)),
);
}
}
// Cleanup temp cover from Go backend
if (coverPath != null && coverPath.isNotEmpty) {
try { await File(coverPath).delete(); } catch (_) {}
}
} else {
if (mounted) {
final error = result['error']?.toString() ?? 'Unknown error';
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(error))),
);
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))),
);
}
}
}
String _cleanLrcForDisplay(String lrc) { String _cleanLrcForDisplay(String lrc) {
final lines = lrc.split('\n'); final lines = lrc.split('\n');
final cleanLines = <String>[]; final cleanLines = <String>[];
@@ -1148,8 +1542,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
isScrollControlled: true,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
builder: (context) => SafeArea( builder: (context) => SafeArea(
child: Column( child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -1170,6 +1569,46 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
_copyToClipboard(context, cleanFilePath); _copyToClipboard(context, cleanFilePath);
}, },
), ),
if (_fileExists)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: Text(context.l10n.trackEditMetadata),
onTap: () {
Navigator.pop(context);
_showEditMetadataSheet(context, ref, colorScheme);
},
),
if (!_isLocalItem && (_coverUrl != null || _fileExists))
ListTile(
leading: const Icon(Icons.image_outlined),
title: Text(context.l10n.trackSaveCoverArt),
subtitle: Text(context.l10n.trackSaveCoverArtSubtitle),
onTap: () {
Navigator.pop(context);
_saveCoverArt();
},
),
if (!_isLocalItem)
ListTile(
leading: const Icon(Icons.lyrics_outlined),
title: Text(context.l10n.trackSaveLyrics),
subtitle: Text(context.l10n.trackSaveLyricsSubtitle),
onTap: () {
Navigator.pop(context);
_saveLyrics();
},
),
if (_fileExists)
ListTile(
leading: const Icon(Icons.travel_explore),
title: Text(context.l10n.trackReEnrich),
subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle),
onTap: () {
Navigator.pop(context);
_reEnrichMetadata();
},
),
const Divider(height: 1),
ListTile( ListTile(
leading: const Icon(Icons.share), leading: const Icon(Icons.share),
title: Text(context.l10n.trackMetadataShare), title: Text(context.l10n.trackMetadataShare),
@@ -1189,10 +1628,75 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
), ),
),
), ),
); );
} }
void _showEditMetadataSheet(BuildContext context, WidgetRef ref, ColorScheme colorScheme) async {
// Read current metadata from file, fall back to item data on failure
Map<String, dynamic>? fileMetadata;
try {
final result = await PlatformBridge.readFileMetadata(cleanFilePath);
if (result['error'] == null) {
fileMetadata = result;
}
} catch (e) {
debugPrint('readFileMetadata failed, using item data: $e');
}
// Build initial values map prefer file metadata, fall back to item data
String val(String key, String? fallback) {
final v = fileMetadata?[key]?.toString();
return (v != null && v.isNotEmpty) ? v : (fallback ?? '');
}
final initialValues = <String, String>{
'title': val('title', trackName),
'artist': val('artist', artistName),
'album': val('album', albumName),
'album_artist': val('album_artist', albumArtist),
'date': val('date', releaseDate),
'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '').toString(),
'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '').toString(),
'genre': val('genre', genre),
'isrc': val('isrc', isrc),
'label': val('label', label),
'copyright': val('copyright', copyright),
'composer': fileMetadata?['composer']?.toString() ?? '',
'comment': fileMetadata?['comment']?.toString() ?? '',
};
if (!context.mounted) return;
final saved = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (sheetContext) => _EditMetadataSheet(
colorScheme: colorScheme,
initialValues: initialValues,
filePath: cleanFilePath,
),
);
if (saved == true && mounted) {
ScaffoldMessenger.of(this.context).showSnackBar(
const SnackBar(content: Text('Metadata saved successfully')),
);
// Re-read metadata from file to refresh the display
try {
final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath);
setState(() => _editedMetadata = refreshed);
} catch (_) {
setState(() {});
}
}
}
void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) {
showDialog( showDialog(
context: context, context: context,
@@ -1324,17 +1828,380 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
Color _getServiceColor(String service, ColorScheme colorScheme) { Color _getServiceColor(String service, ColorScheme colorScheme) {
switch (service.toLowerCase()) { switch (service.toLowerCase()) {
case 'tidal': case 'tidal':
return const Color(0xFF0077B5); // Tidal blue (darker, more readable) return const Color(0xFF0077B5);
case 'qobuz': case 'qobuz':
return const Color(0xFF0052CC); // Qobuz blue return const Color(0xFF0052CC);
case 'amazon': case 'amazon':
return const Color(0xFFFF9900); // Amazon orange return const Color(0xFFFF9900);
default: default:
return colorScheme.primary; return colorScheme.primary;
} }
} }
} }
class _EditMetadataSheet extends StatefulWidget {
final ColorScheme colorScheme;
final Map<String, String> initialValues;
final String filePath;
const _EditMetadataSheet({
required this.colorScheme,
required this.initialValues,
required this.filePath,
});
@override
State<_EditMetadataSheet> createState() => _EditMetadataSheetState();
}
class _EditMetadataSheetState extends State<_EditMetadataSheet> {
bool _saving = false;
bool _showAdvanced = false;
late final TextEditingController _titleCtrl;
late final TextEditingController _artistCtrl;
late final TextEditingController _albumCtrl;
late final TextEditingController _albumArtistCtrl;
late final TextEditingController _dateCtrl;
late final TextEditingController _trackNumCtrl;
late final TextEditingController _discNumCtrl;
late final TextEditingController _genreCtrl;
late final TextEditingController _isrcCtrl;
late final TextEditingController _labelCtrl;
late final TextEditingController _copyrightCtrl;
late final TextEditingController _composerCtrl;
late final TextEditingController _commentCtrl;
@override
void initState() {
super.initState();
final v = widget.initialValues;
_titleCtrl = TextEditingController(text: v['title'] ?? '');
_artistCtrl = TextEditingController(text: v['artist'] ?? '');
_albumCtrl = TextEditingController(text: v['album'] ?? '');
_albumArtistCtrl = TextEditingController(text: v['album_artist'] ?? '');
_dateCtrl = TextEditingController(text: v['date'] ?? '');
_trackNumCtrl = TextEditingController(text: v['track_number'] ?? '');
_discNumCtrl = TextEditingController(text: v['disc_number'] ?? '');
_genreCtrl = TextEditingController(text: v['genre'] ?? '');
_isrcCtrl = TextEditingController(text: v['isrc'] ?? '');
_labelCtrl = TextEditingController(text: v['label'] ?? '');
_copyrightCtrl = TextEditingController(text: v['copyright'] ?? '');
_composerCtrl = TextEditingController(text: v['composer'] ?? '');
_commentCtrl = TextEditingController(text: v['comment'] ?? '');
}
@override
void dispose() {
_titleCtrl.dispose();
_artistCtrl.dispose();
_albumCtrl.dispose();
_albumArtistCtrl.dispose();
_dateCtrl.dispose();
_trackNumCtrl.dispose();
_discNumCtrl.dispose();
_genreCtrl.dispose();
_isrcCtrl.dispose();
_labelCtrl.dispose();
_copyrightCtrl.dispose();
_composerCtrl.dispose();
_commentCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
final metadata = <String, String>{
'title': _titleCtrl.text,
'artist': _artistCtrl.text,
'album': _albumCtrl.text,
'album_artist': _albumArtistCtrl.text,
'date': _dateCtrl.text,
'track_number': _trackNumCtrl.text,
'disc_number': _discNumCtrl.text,
'genre': _genreCtrl.text,
'isrc': _isrcCtrl.text,
'label': _labelCtrl.text,
'copyright': _copyrightCtrl.text,
'composer': _composerCtrl.text,
'comment': _commentCtrl.text,
};
try {
final result = await PlatformBridge.editFileMetadata(
widget.filePath,
metadata,
);
if (result['error'] != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${result['error']}')),
);
}
setState(() => _saving = false);
return;
}
final method = result['method'] as String?;
if (method == 'ffmpeg') {
// MP3/Opus: use FFmpeg to write metadata
// For SAF files, Kotlin returns temp_path + saf_uri
final tempPath = result['temp_path'] as String?;
final safUri = result['saf_uri'] as String?;
final ffmpegTarget = tempPath ?? widget.filePath;
final lower = widget.filePath.toLowerCase();
final isMp3 = lower.endsWith('.mp3');
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
final vorbisMap = <String, String>{};
if (metadata['title']?.isNotEmpty == true) vorbisMap['TITLE'] = metadata['title']!;
if (metadata['artist']?.isNotEmpty == true) vorbisMap['ARTIST'] = metadata['artist']!;
if (metadata['album']?.isNotEmpty == true) vorbisMap['ALBUM'] = metadata['album']!;
if (metadata['album_artist']?.isNotEmpty == true) vorbisMap['ALBUMARTIST'] = metadata['album_artist']!;
if (metadata['date']?.isNotEmpty == true) vorbisMap['DATE'] = metadata['date']!;
if (metadata['track_number']?.isNotEmpty == true && metadata['track_number'] != '0') {
vorbisMap['TRACKNUMBER'] = metadata['track_number']!;
}
if (metadata['disc_number']?.isNotEmpty == true && metadata['disc_number'] != '0') {
vorbisMap['DISCNUMBER'] = metadata['disc_number']!;
}
if (metadata['genre']?.isNotEmpty == true) vorbisMap['GENRE'] = metadata['genre']!;
if (metadata['isrc']?.isNotEmpty == true) vorbisMap['ISRC'] = metadata['isrc']!;
if (metadata['label']?.isNotEmpty == true) vorbisMap['ORGANIZATION'] = metadata['label']!;
if (metadata['copyright']?.isNotEmpty == true) vorbisMap['COPYRIGHT'] = metadata['copyright']!;
if (metadata['composer']?.isNotEmpty == true) vorbisMap['COMPOSER'] = metadata['composer']!;
if (metadata['comment']?.isNotEmpty == true) vorbisMap['COMMENT'] = metadata['comment']!;
// Extract existing cover art before re-embedding metadata
String? existingCoverPath;
try {
final tempDir = await Directory.systemTemp.createTemp('cover_');
final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg';
final coverResult = await PlatformBridge.extractCoverToFile(ffmpegTarget, coverOutput);
if (coverResult['error'] == null) {
existingCoverPath = coverOutput;
}
} catch (_) {
// No cover to preserve, continue without
}
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
mp3Path: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
);
} else if (isOpus) {
ffmpegResult = await FFmpegService.embedMetadataToOpus(
opusPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
);
}
// Cleanup temp cover
if (existingCoverPath != null) {
try { await File(existingCoverPath).delete(); } catch (_) {}
}
if (ffmpegResult == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to save metadata via FFmpeg')),
);
}
setState(() => _saving = false);
return;
}
// For SAF files, copy the processed temp file back
if (tempPath != null && safUri != null) {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to write metadata back to storage')),
);
setState(() => _saving = false);
return;
}
}
}
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to save metadata: $e')),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final cs = widget.colorScheme;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Column(
children: [
// Handle bar
Padding(
padding: const EdgeInsets.only(top: 12, bottom: 8),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: cs.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Title row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Row(
children: [
Expanded(
child: Text(
'Edit Metadata',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (_saving)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
FilledButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
),
const SizedBox(height: 12),
// Fields
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24),
children: [
_field('Title', _titleCtrl),
_field('Artist', _artistCtrl),
_field('Album', _albumCtrl),
_field('Album Artist', _albumArtistCtrl),
_field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'),
Row(
children: [
Expanded(child: _field('Track #', _trackNumCtrl, keyboard: TextInputType.number)),
const SizedBox(width: 12),
Expanded(child: _field('Disc #', _discNumCtrl, keyboard: TextInputType.number)),
],
),
_field('Genre', _genreCtrl),
_field('ISRC', _isrcCtrl),
// Advanced fields toggle
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: InkWell(
onTap: () => setState(() => _showAdvanced = !_showAdvanced),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Icon(
_showAdvanced ? Icons.expand_less : Icons.expand_more,
size: 20,
color: cs.onSurfaceVariant,
),
const SizedBox(width: 8),
Text(
'Advanced',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: cs.onSurfaceVariant,
),
),
],
),
),
),
),
if (_showAdvanced) ...[
_field('Label', _labelCtrl),
_field('Copyright', _copyrightCtrl),
_field('Composer', _composerCtrl),
_field('Comment', _commentCtrl, maxLines: 3),
],
const SizedBox(height: 24),
],
),
),
],
),
),
);
}
Widget _field(
String label,
TextEditingController controller, {
String? hint,
TextInputType? keyboard,
int maxLines = 1,
}) {
final cs = widget.colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
keyboardType: keyboard,
maxLines: maxLines,
decoration: InputDecoration(
labelText: label,
hintText: hint,
filled: true,
fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: cs.primary, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
);
}
}
class _MetadataItem { class _MetadataItem {
final String label; final String label;
final String value; final String value;
+161 -105
View File
@@ -16,6 +16,26 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
int _currentPage = 0; int _currentPage = 0;
static const int _totalPages = 6; 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 @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
@@ -55,6 +75,15 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final l10n = context.l10n; final l10n = context.l10n;
final isLastPage = _currentPage == _totalPages - 1; 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( return Scaffold(
backgroundColor: colorScheme.surface, backgroundColor: colorScheme.surface,
@@ -63,7 +92,10 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
children: [ children: [
// Top Navigation Bar // Top Navigation Bar
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
vertical: topBarPaddingV,
),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -199,9 +231,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
margin: const EdgeInsets.symmetric(horizontal: 4), margin: EdgeInsets.symmetric(horizontal: 4 * scale),
height: 8, height: pageIndicatorHeight,
width: isActive ? 32 : 8, width: isActive
? activeIndicatorWidth
: pageIndicatorWidth,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive color: isActive
? colorScheme.primary ? colorScheme.primary
@@ -211,11 +245,11 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
); );
}), }),
), ),
const SizedBox(height: 32), SizedBox(height: bottomGap),
// Action Button // Action Button
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
height: 56, height: actionButtonHeight,
child: FilledButton( child: FilledButton(
onPressed: _nextPage, onPressed: _nextPage,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@@ -520,104 +554,114 @@ class _InteractiveDownloadExampleState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Container( return LayoutBuilder(
padding: const EdgeInsets.all(20), builder: (context, constraints) {
decoration: BoxDecoration( final cardWidth = constraints.maxWidth;
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
borderRadius: BorderRadius.circular(28), final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
border: Border.all( final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
), return Container(
), padding: const EdgeInsets.all(20),
child: Row( decoration: BoxDecoration(
children: [ color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
Container( borderRadius: BorderRadius.circular(28),
width: 72, border: Border.all(
height: 72, color: colorScheme.outlineVariant.withValues(alpha: 0.5),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: 36,
color: colorScheme.onPrimaryContainer,
), ),
), ),
const SizedBox(width: 20), child: Row(
Expanded( children: [
child: Column( Container(
crossAxisAlignment: CrossAxisAlignment.start, width: coverSize,
children: [ height: coverSize,
Container( decoration: BoxDecoration(
width: 140, color: colorScheme.primaryContainer,
height: 14, borderRadius: BorderRadius.circular(20),
decoration: BoxDecoration( ),
color: colorScheme.onSurface, child: Icon(
borderRadius: BorderRadius.circular(7), 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 const SizedBox(width: 20),
? SizedBox( Expanded(
width: 28, child: Column(
height: 28, crossAxisAlignment: CrossAxisAlignment.start,
child: CircularProgressIndicator( children: [
strokeWidth: 3, Container(
color: colorScheme.onPrimary, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; 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) // Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable // In a real advanced implementation we'd pass the Controller's listenable
@@ -656,23 +712,23 @@ class _TutorialPage extends StatelessWidget {
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 24), SizedBox(height: topGap),
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0), transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
padding: const EdgeInsets.all(24), padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15), color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
icon, icon,
size: 56, size: iconSize,
color: iconColor ?? colorScheme.primary, color: iconColor ?? colorScheme.primary,
), ),
), ),
const SizedBox(height: 48), SizedBox(height: iconTextGap),
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0, opacity: isActive ? 1.0 : 0.0,
@@ -687,7 +743,7 @@ class _TutorialPage extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 20), SizedBox(height: descriptionGap),
AnimatedOpacity( AnimatedOpacity(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0, opacity: isActive ? 1.0 : 0.0,
@@ -697,14 +753,14 @@ class _TutorialPage extends StatelessWidget {
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
height: 1.5, height: 1.5,
fontSize: 16, fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
const SizedBox(height: 56), SizedBox(height: contentGap),
content, // The content itself now handles its own internal animations content, // The content itself now handles its own internal animations
const SizedBox(height: 32), SizedBox(height: bottomGap),
], ],
), ),
); );
+21 -6
View File
@@ -11,6 +11,7 @@ final _log = AppLogger('FFmpeg');
class FFmpegService { class FFmpegService {
static const int _commandLogPreviewLength = 300; static const int _commandLogPreviewLength = 300;
static int _tempEmbedCounter = 0;
static String _buildOutputPath(String inputPath, String extension) { static String _buildOutputPath(String inputPath, String extension) {
final normalizedExt = extension.startsWith('.') ? extension : '.$extension'; final normalizedExt = extension.startsWith('.') ? extension : '.$extension';
@@ -47,6 +48,14 @@ class FFmpegService {
return '${redacted.substring(0, _commandLogPreviewLength)}...'; 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 { static Future<FFmpegResult> _execute(String command) async {
try { try {
final session = await FFmpegKit.execute(command); final session = await FFmpegKit.execute(command);
@@ -269,8 +278,7 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.flac';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$flacPath" '); cmdBuffer.write('-i "$flacPath" ');
@@ -347,8 +355,7 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.mp3');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.mp3';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$mp3Path" '); cmdBuffer.write('-i "$mp3Path" ');
@@ -358,6 +365,7 @@ class FFmpegService {
} }
cmdBuffer.write('-map 0:a '); cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
if (coverPath != null) { if (coverPath != null) {
cmdBuffer.write('-map 1:0 '); cmdBuffer.write('-map 1:0 ');
@@ -429,12 +437,13 @@ class FFmpegService {
Map<String, String>? metadata, Map<String, String>? metadata,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final uniqueId = DateTime.now().millisecondsSinceEpoch; final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
final tempOutput = '${tempDir.path}/temp_embed_$uniqueId.opus';
final StringBuffer cmdBuffer = StringBuffer(); final StringBuffer cmdBuffer = StringBuffer();
cmdBuffer.write('-i "$opusPath" '); cmdBuffer.write('-i "$opusPath" ');
cmdBuffer.write('-map 0:a '); cmdBuffer.write('-map 0:a ');
cmdBuffer.write('-map_metadata -1 ');
cmdBuffer.write('-map_metadata:s:a -1 ');
cmdBuffer.write('-c:a copy '); cmdBuffer.write('-c:a copy ');
if (metadata != null) { if (metadata != null) {
@@ -648,6 +657,12 @@ class FFmpegService {
case 'UNSYNCEDLYRICS': case 'UNSYNCEDLYRICS':
id3Map['lyrics'] = value; id3Map['lyrics'] = value;
break; break;
case 'COMPOSER':
id3Map['composer'] = value;
break;
case 'COMMENT':
id3Map['comment'] = value;
break;
default: default:
id3Map[key.toLowerCase()] = value; id3Map[key.toLowerCase()] = value;
} }
+133
View File
@@ -374,6 +374,55 @@ class PlatformBridge {
await _channel.invokeMethod('cleanupConnections'); await _channel.invokeMethod('cleanupConnections');
} }
static Future<Map<String, dynamic>> downloadCoverToFile(
String coverUrl,
String outputPath, {
bool maxQuality = true,
}) async {
final result = await _channel.invokeMethod('downloadCoverToFile', {
'cover_url': coverUrl,
'output_path': outputPath,
'max_quality': maxQuality,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> extractCoverToFile(
String audioPath,
String outputPath,
) async {
final result = await _channel.invokeMethod('extractCoverToFile', {
'audio_path': audioPath,
'output_path': outputPath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> fetchAndSaveLyrics({
required String trackName,
required String artistName,
required String spotifyId,
required int durationMs,
required String outputPath,
}) async {
final result = await _channel.invokeMethod('fetchAndSaveLyrics', {
'track_name': trackName,
'artist_name': artistName,
'spotify_id': spotifyId,
'duration_ms': durationMs,
'output_path': outputPath,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> reEnrichFile(Map<String, dynamic> request) async {
final requestJSON = jsonEncode(request);
final result = await _channel.invokeMethod('reEnrichFile', {
'request_json': requestJSON,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> readFileMetadata(String filePath) async { static Future<Map<String, dynamic>> readFileMetadata(String filePath) async {
final result = await _channel.invokeMethod('readFileMetadata', { final result = await _channel.invokeMethod('readFileMetadata', {
'file_path': filePath, 'file_path': filePath,
@@ -381,6 +430,27 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>; return jsonDecode(result as String) as Map<String, dynamic>;
} }
static Future<Map<String, dynamic>> editFileMetadata(
String filePath,
Map<String, String> metadata,
) async {
final metadataJSON = jsonEncode(metadata);
final result = await _channel.invokeMethod('editFileMetadata', {
'file_path': filePath,
'metadata_json': metadataJSON,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<bool> writeTempToSaf(String tempPath, String safUri) async {
final result = await _channel.invokeMethod('writeTempToSaf', {
'temp_path': tempPath,
'saf_uri': safUri,
});
final map = jsonDecode(result as String) as Map<String, dynamic>;
return map['success'] == true;
}
static Future<void> startDownloadService({ static Future<void> startDownloadService({
String trackName = '', String trackName = '',
String artistName = '', String artistName = '',
@@ -1117,4 +1187,67 @@ static Future<Map<String, dynamic>> downloadWithExtensions({
_log.d('clearStoreCache'); _log.d('clearStoreCache');
await _channel.invokeMethod('clearStoreCache'); await _channel.invokeMethod('clearStoreCache');
} }
// ==================== YOUTUBE / COBALT ====================
/// Download a track from YouTube using the Cobalt API.
/// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps).
/// It does NOT participate in the lossless fallback chain.
static Future<Map<String, dynamic>> downloadFromYouTube({
required String trackName,
required String artistName,
required String albumName,
String? albumArtist,
String? coverUrl,
required String outputDir,
required String filenameFormat,
String quality = 'opus_256',
int trackNumber = 1,
int discNumber = 1,
String? releaseDate,
String? itemId,
int durationMs = 0,
String? isrc,
String? spotifyId,
String? deezerId,
String storageMode = 'app',
String safTreeUri = '',
String safRelativeDir = '',
String safFileName = '',
String safOutputExt = '',
}) async {
_log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)');
final request = jsonEncode({
'track_name': trackName,
'artist_name': artistName,
'album_name': albumName,
'album_artist': albumArtist ?? artistName,
'cover_url': coverUrl,
'output_dir': outputDir,
'filename_format': filenameFormat,
'quality': quality,
'track_number': trackNumber,
'disc_number': discNumber,
'release_date': releaseDate ?? '',
'item_id': itemId ?? '',
'duration_ms': durationMs,
'isrc': isrc ?? '',
'spotify_id': spotifyId ?? '',
'deezer_id': deezerId ?? '',
'storage_mode': storageMode,
'saf_tree_uri': safTreeUri,
'saf_relative_dir': safRelativeDir,
'saf_file_name': safFileName,
'saf_output_ext': safOutputExt,
});
final result = await _channel.invokeMethod('downloadFromYouTube', request);
final response = jsonDecode(result as String) as Map<String, dynamic>;
if (response['success'] == true) {
_log.i('YouTube download success: ${response['file_path']}');
} else {
_log.w('YouTube download failed: ${response['error']}');
}
return response;
}
} }
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
const double kNormalizedHeaderTopPadding = 24.0;
double normalizedHeaderTopPadding(
BuildContext context, {
double max = kNormalizedHeaderTopPadding,
}) {
if (defaultTargetPlatform == TargetPlatform.iOS ||
defaultTargetPlatform == TargetPlatform.macOS) {
return 0;
}
final topPadding = MediaQuery.paddingOf(context).top;
if (topPadding <= 0) return 0;
return topPadding > max ? max : topPadding;
}
+129
View File
@@ -1,9 +1,138 @@
import 'dart:io'; import 'dart:io';
import 'package:open_filex/open_filex.dart'; import 'package:open_filex/open_filex.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/mime_utils.dart';
/// Regular expression to detect iOS app container paths.
/// Matches paths like /var/mobile/Containers/Data/Application/{UUID}
/// or /private/var/mobile/Containers/Data/Application/{UUID}
final _iosContainerRootPattern = RegExp(
r'^(/private)?/var/mobile/Containers/Data/Application/[A-F0-9\-]+/?$',
caseSensitive: false,
);
/// Checks if a path is a valid writable directory on iOS.
/// Returns false if:
/// - The path is the app container root (not writable)
/// - The path is an iCloud Drive path (not accessible by Go backend)
/// - The path is outside the app sandbox
bool isValidIosWritablePath(String path) {
if (!Platform.isIOS) return true;
if (path.isEmpty) return false;
// Check if it's the container root (without Documents/, tmp/, etc.)
if (_iosContainerRootPattern.hasMatch(path)) {
return false;
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return false;
}
// Ensure path contains a valid subdirectory (Documents, tmp, Library, etc.)
// This handles cases where FilePicker returns container root
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
);
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
// Valid paths should have something after the UUID
if (remainingPath.isEmpty || remainingPath == '/') {
return false;
}
}
return true;
}
/// Validates and potentially corrects an iOS path.
/// Returns a valid Documents subdirectory path if the input is invalid.
Future<String> validateOrFixIosPath(String path, {String subfolder = 'SpotiFLAC'}) async {
if (!Platform.isIOS) return path;
if (isValidIosWritablePath(path)) {
return path;
}
// Fall back to app Documents directory
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/$subfolder');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
return musicDir.path;
}
/// Detailed result for iOS path validation
class IosPathValidationResult {
final bool isValid;
final String? correctedPath;
final String? errorReason;
const IosPathValidationResult({
required this.isValid,
this.correctedPath,
this.errorReason,
});
}
/// Validates an iOS path and returns detailed information about the result.
IosPathValidationResult validateIosPath(String path) {
if (!Platform.isIOS) {
return const IosPathValidationResult(isValid: true);
}
if (path.isEmpty) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Path is empty',
);
}
// Check if it's the container root
if (_iosContainerRootPattern.hasMatch(path)) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please choose a subfolder like Documents.',
);
}
// Check for iCloud Drive paths
if (path.contains('Mobile Documents') ||
path.contains('CloudDocs') ||
path.contains('com~apple~CloudDocs')) {
return const IosPathValidationResult(
isValid: false,
errorReason: 'iCloud Drive is not supported. Please choose a local folder.',
);
}
// Check for container root without subdirectory
final containerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+',
caseSensitive: false,
);
final match = containerPattern.firstMatch(path);
if (match != null) {
final remainingPath = path.substring(match.end);
if (remainingPath.isEmpty || remainingPath == '/') {
return const IosPathValidationResult(
isValid: false,
errorReason: 'Cannot write to app container root. Please use the default folder or choose a different location.',
);
}
}
return const IosPathValidationResult(isValid: true);
}
class FileAccessStat { class FileAccessStat {
final int? size; final int? size;
final DateTime? modified; final DateTime? modified;
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
/// A collapsing header widget /// A collapsing header widget
/// Title collapses from large to small when scrolling /// Title collapses from large to small when scrolling
@@ -19,7 +20,7 @@ class CollapsingHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final topPadding = MediaQuery.of(context).padding.top; final topPadding = normalizedHeaderTopPadding(context);
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
+25 -137
View File
@@ -22,7 +22,9 @@ class BuiltInService {
}); });
} }
/// Default quality options for built-in services (Tidal, Qobuz, Amazon) /// Default quality options for built-in services (Tidal, Qobuz, YouTube)
/// Note: Amazon is fallback-only and not shown in picker
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
const _builtInServices = [ const _builtInServices = [
BuiltInService( BuiltInService(
id: 'tidal', id: 'tidal',
@@ -31,7 +33,6 @@ const _builtInServices = [
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'),
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
QualityOption(id: 'HIGH', label: 'Lossy 320kbps', description: 'MP3 or Opus (smaller files)'),
], ],
), ),
BuiltInService( BuiltInService(
@@ -44,15 +45,14 @@ const _builtInServices = [
], ],
), ),
BuiltInService( BuiltInService(
id: 'amazon', id: 'youtube',
label: 'Amazon', label: 'YouTube',
qualityOptions: [ qualityOptions: [
QualityOption(id: 'LOSSLESS', label: 'FLAC Lossless', description: '16-bit / 44.1kHz'), QualityOption(id: 'opus_256', label: 'Opus 256kbps', description: 'Best quality lossy (~8MB per track)'),
QualityOption(id: 'HI_RES', label: 'Hi-Res FLAC', description: '24-bit / up to 96kHz'), QualityOption(id: 'mp3_320', label: 'MP3 320kbps', description: 'Best compatibility (~10MB per track)'),
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
], ],
isDisabled: true, isDisabled: false,
disabledReason: 'Fallback only', disabledReason: null,
), ),
]; ];
@@ -211,7 +211,7 @@ Padding(
), ),
), ),
if (_builtInServices.any((s) => s.id == _selectedService)) if (_builtInServices.any((s) => s.id == _selectedService && s.id != 'youtube'))
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text( child: Text(
@@ -223,19 +223,26 @@ Padding(
), ),
), ),
if (_selectedService == 'youtube')
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
context.l10n.youtubeQualityNote,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
for (final quality in qualityOptions) for (final quality in qualityOptions)
_QualityOption( _QualityOption(
title: quality.label, title: quality.label,
subtitle: quality.description ?? '', subtitle: quality.description ?? '',
icon: _getQualityIcon(quality.id), icon: _getQualityIcon(quality.id),
onTap: () { onTap: () {
// For Tidal HIGH quality, show format picker first Navigator.pop(context);
if (_selectedService == 'tidal' && quality.id == 'HIGH') { widget.onSelect(quality.id, _selectedService);
_showLossyFormatPicker(context);
} else {
Navigator.pop(context);
widget.onSelect(quality.id, _selectedService);
}
}, },
), ),
@@ -254,136 +261,17 @@ Padding(
return Icons.high_quality; return Icons.high_quality;
case 'LOSSLESS': case 'LOSSLESS':
return Icons.music_note; return Icons.music_note;
case 'HIGH':
return Icons.aod;
case 'MP3_320': case 'MP3_320':
case 'MP3': case 'MP3':
return Icons.audiotrack; return Icons.audiotrack;
case 'OPUS': case 'OPUS':
case 'OPUS_128': case 'OPUS_128':
case 'OPUS_256':
return Icons.graphic_eq; return Icons.graphic_eq;
default: default:
return Icons.music_note; return Icons.music_note;
} }
} }
void _showLossyFormatPicker(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
final currentFormat = settings.tidalHighFormat;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (modalContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text(
'Select Lossy Format',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose output format for 320kbps lossy download',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.audiotrack, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('MP3 320kbps'),
subtitle: const Text('Best compatibility, ~10MB per track'),
trailing: currentFormat == 'mp3_320'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('mp3_320');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 256kbps'),
subtitle: const Text('Best quality Opus, ~8MB per track'),
trailing: currentFormat == 'opus_256'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_256');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.graphic_eq, color: colorScheme.onPrimaryContainer, size: 20),
),
title: const Text('Opus 128kbps'),
subtitle: const Text('Smallest size, ~4MB per track'),
trailing: currentFormat == 'opus_128'
? Icon(Icons.check_circle, color: colorScheme.primary)
: null,
onTap: () {
ref.read(settingsProvider.notifier).setTidalHighFormat('opus_128');
Navigator.pop(modalContext); // Close format picker
Navigator.pop(context); // Close service picker
widget.onSelect('HIGH', _selectedService);
},
),
const SizedBox(height: 16),
],
),
),
);
}
} }
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.5.2+76 version: 3.6.0+77
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0