Compare commits

...

16 Commits

Author SHA1 Message Date
zarzet dd750b95ca chore: bump version to 4.1.3 (build 120) 2026-03-30 18:25:42 +07:00
zarzet e42e44f28b fix: Samsung SAF library scan, Qobuz album cover, M4A metadata save and log improvements
- Fix M4A/ALAC scan silently failing on Samsung by adding proper fallback
  to scanFromFilename when ReadM4ATags fails (consistent with MP3/FLAC/Ogg)
- Propagate displayNameHint to all format scanners so fd numbers (214, 207)
  no longer appear as track names when /proc/self/fd/ paths are used
- Cache /proc/self/fd/ readability in Kotlin to skip failed attempts after
  first failure, reducing error log noise and improving scan speed on Samsung
- Fix Qobuz download returning wrong album cover when track exists on
  multiple albums by preferring req.CoverURL over API default
- Fix FFmpeg M4A metadata save failing with 'codec not currently supported
  in container' by forcing mp4 muxer instead of ipod when cover art present
- Clean up FLAC SAF temp file after metadata write-back (was leaking)
- Update LRC lyrics tag to credit Paxsenix API
- Remove log message truncation, defer to UI preview truncation instead
2026-03-30 18:12:20 +07:00
zarzet 67daefdf60 feat: add artist tag mode setting with split Vorbis support and improve library scan progress
- Add artist_tag_mode setting (joined / split_vorbis) for FLAC/Opus multi-artist tags
- Split 'Artist A, Artist B' into separate ARTIST= Vorbis comments when split mode is enabled
- Join repeated ARTIST/ALBUMARTIST Vorbis comments when reading metadata
- Propagate artistTagMode through download pipeline, re-enrich, and metadata editor
- Improve library scan progress: separate polling intervals, finalizing state, indeterminate progress
- Add initial progress snapshot on library scan stream connect
- Use req.ArtistName consistently for Qobuz downloads instead of track.Performer.Name
- Add l10n keys for artist tag mode, library files unit, and scan finalizing status
2026-03-30 12:38:42 +07:00
zarzet fabaf0a3ff feat: add stable cover cache keys, Qobuz album-search fallback, metadata filters and extended sort options
- Introduce coverCacheKey parameter through Go backend and Kotlin bridge for stable SAF cover caching
- Add MetadataFromFilename flag to skip filename-only metadata and retry via temp-file copy
- Add Qobuz album-search fallback between API search and store scraping
- Extract buildReEnrichFFmpegMetadata to skip empty metadata fields
- Add metadata completeness filter (complete, missing year/genre/album artist)
- Add sort modes: artist, album, release date, genre (asc/desc)
- Prune stale library cover cache files after full scan
- Skip empty values and zero track/disc numbers in FFmpeg metadata
- Add new l10n keys for metadata filter and sort options
2026-03-30 11:41:11 +07:00
zarzet fb90c73f42 fix: use Tidal quality options as fallback instead of DEFAULT for extensions 2026-03-29 18:57:13 +07:00
zarzet c6cf65f075 fix: normalize DEFAULT quality to prevent Tidal/Qobuz API failures 2026-03-29 18:49:57 +07:00
zarzet 25de009ebc feat: replace batch operation snackbars with progress dialog
Add reusable BatchProgressDialog widget with circular/linear progress
indicators, cancel support, and track detail display. Uses ValueNotifier
pattern to communicate progress from caller to dialog across navigator
routes.
2026-03-29 18:04:38 +07:00
zarzet 8918d74bb5 refactor: extract and improve ReEnrich track selection with scoring-based matching 2026-03-29 17:45:51 +07:00
zarzet f9de8d45d9 fix: add attached_pic disposition to ALAC cover art embedding 2026-03-29 17:41:43 +07:00
zarzet 48eef0853d i18n: extract hardcoded strings into l10n keys
Move hardcoded UI strings across multiple screens and the notification
service into ARB-backed l10n keys so they can be translated via Crowdin.
Adds 62 new keys covering sort labels, dialog copy, metadata error
snackbars, folder-picker errors, home-tab error states, extensions home
feed selector, and all notification titles/bodies. NotificationService
now caches an AppLocalizations instance (injected from MainShell via
didChangeDependencies) and falls back to English literals when no locale
is available.
2026-03-29 17:02:12 +07:00
zarzet fc70a912bf refactor: route spotify URLs through extensions 2026-03-29 16:35:16 +07:00
zarzet cd3e5b4b28 chore: bump version to 4.1.2+119 2026-03-29 15:40:24 +07:00
zarzet 482ca82eb4 feat: improve track matching 2026-03-29 15:34:44 +07:00
zarzet 6d87ae5484 feat: add haptic feedback when swiping library tabs 2026-03-29 01:56:22 +07:00
zarzet bd3e2b999b feat: add play button to playlist/library track tiles
Show a play IconButton (matching local album style) next to the
more-options button when a track has a local file available.
Uses PlaybackController.playTrackList to resolve and open the file.
2026-03-29 01:54:27 +07:00
zarzet 186196e12b fix: use START_NOT_STICKY for DownloadService to prevent auto-restart
Prevents Android from automatically recreating the download service
after it is killed, avoiding duplicate or orphaned download processes.
2026-03-29 01:37:24 +07:00
71 changed files with 7382 additions and 978 deletions
@@ -104,7 +104,7 @@ class DownloadService : Service() {
updateNotification(progress, total) updateNotification(progress, total)
} }
} }
return START_STICKY return START_NOT_STICKY
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -40,7 +40,8 @@ class MainActivity: FlutterFragmentActivity() {
"com.zarz.spotiflac/download_progress_stream" "com.zarz.spotiflac/download_progress_stream"
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL = private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
"com.zarz.spotiflac/library_scan_progress_stream" "com.zarz.spotiflac/library_scan_progress_stream"
private val STREAM_POLLING_INTERVAL_MS = 1200L private val DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS = 1200L
private val LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS = 200L
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var pendingSafTreeResult: MethodChannel.Result? = null private var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any() private val safScanLock = Any()
@@ -55,6 +56,8 @@ class MainActivity: FlutterFragmentActivity() {
private var flutterBackCallback: OnBackPressedCallback? = null private var flutterBackCallback: OnBackPressedCallback? = null
@Volatile private var safScanCancel = false @Volatile private var safScanCancel = false
@Volatile private var safScanActive = false @Volatile private var safScanActive = false
/** Tri-state: null = untested, true = works, false = fails (Samsung SELinux). */
@Volatile private var procSelfFdReadable: Boolean? = null
private val safTreeLauncher = registerForActivityResult( private val safTreeLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult() ActivityResultContracts.StartActivityForResult()
) { activityResult -> ) { activityResult ->
@@ -374,6 +377,8 @@ class MainActivity: FlutterFragmentActivity() {
synchronized(safScanLock) { synchronized(safScanLock) {
safScanProgress = SafScanProgress() safScanProgress = SafScanProgress()
} }
// Allow re-probing /proc/self/fd readability on every new scan session.
procSelfFdReadable = null
} }
private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) { private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) {
@@ -454,7 +459,7 @@ class MainActivity: FlutterFragmentActivity() {
"Download progress stream poll failed: ${e.message}", "Download progress stream poll failed: ${e.message}",
) )
} }
delay(STREAM_POLLING_INTERVAL_MS) delay(DOWNLOAD_PROGRESS_STREAM_POLLING_INTERVAL_MS)
} }
} }
} }
@@ -471,6 +476,18 @@ class MainActivity: FlutterFragmentActivity() {
libraryScanProgressEventSink = sink libraryScanProgressEventSink = sink
lastLibraryScanProgressPayload = null lastLibraryScanProgressPayload = null
libraryScanProgressStreamJob = scope.launch { libraryScanProgressStreamJob = scope.launch {
try {
val initialPayload = withContext(Dispatchers.IO) {
readLibraryScanProgressJsonForStream()
}
lastLibraryScanProgressPayload = initialPayload
sink.success(parseJsonPayload(initialPayload))
} catch (e: Exception) {
android.util.Log.w(
"SpotiFLAC",
"Library scan progress initial poll failed: ${e.message}",
)
}
while (isActive && libraryScanProgressEventSink === sink) { while (isActive && libraryScanProgressEventSink === sink) {
try { try {
val payload = withContext(Dispatchers.IO) { val payload = withContext(Dispatchers.IO) {
@@ -486,7 +503,7 @@ class MainActivity: FlutterFragmentActivity() {
"Library scan progress stream poll failed: ${e.message}", "Library scan progress stream poll failed: ${e.message}",
) )
} }
delay(STREAM_POLLING_INTERVAL_MS) delay(LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS)
} }
} }
} }
@@ -777,30 +794,60 @@ class MainActivity: FlutterFragmentActivity() {
return if (ext.isNullOrBlank()) "audio" else "audio$ext" return if (ext.isNullOrBlank()) "audio" else "audio$ext"
} }
private fun buildLibraryCoverCacheKey(stablePath: String, lastModified: Long): String {
val normalizedPath = stablePath.trim()
if (normalizedPath.isEmpty()) return ""
return if (lastModified > 0L) "$normalizedPath|$lastModified" else normalizedPath
}
private fun readAudioMetadataFromUri( private fun readAudioMetadataFromUri(
uri: Uri, uri: Uri,
displayNameHint: String? = null, displayNameHint: String? = null,
fallbackExt: String? = null, fallbackExt: String? = null,
coverCacheKey: String = "",
): JSONObject? { ): JSONObject? {
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt) val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
if (procSelfFdReadable != false) {
try { try {
contentResolver.openFileDescriptor(uri, "r")?.use { pfd -> contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val directPath = "/proc/self/fd/${pfd.fd}" val directPath = "/proc/self/fd/${pfd.fd}"
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName) val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
directPath,
displayName,
coverCacheKey,
)
if (metadataJson.isNotBlank()) { if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson) val obj = JSONObject(metadataJson)
if (!obj.has("error")) { val filenameFallback = obj.optBoolean("metadataFromFilename", false)
if (!obj.has("error") && !filenameFallback) {
procSelfFdReadable = true
return obj return obj
} }
// Go could not read real metadata from the fd path
// remember so we skip the attempt for remaining files.
if (procSelfFdReadable == null) {
procSelfFdReadable = false
android.util.Log.d(
"SpotiFLAC",
"Direct /proc/self/fd read not usable on this device, " +
"using temp-file fallback for remaining files",
)
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
if (procSelfFdReadable == null) {
procSelfFdReadable = false
android.util.Log.d( android.util.Log.d(
"SpotiFLAC", "SpotiFLAC",
"Direct SAF metadata read fallback for $uri: ${e.message}", "Direct /proc/self/fd read not usable on this device, " +
"using temp-file fallback for remaining files",
) )
} }
}
}
val tempPath = try { val tempPath = try {
copyUriToTemp(uri, fallbackExt) copyUriToTemp(uri, fallbackExt)
@@ -813,7 +860,11 @@ class MainActivity: FlutterFragmentActivity() {
} ?: return null } ?: return null
try { try {
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName) val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
tempPath,
displayName,
coverCacheKey,
)
if (metadataJson.isBlank()) return null if (metadataJson.isBlank()) return null
val obj = JSONObject(metadataJson) val obj = JSONObject(metadataJson)
return if (obj.has("error")) null else obj return if (obj.has("error")) null else obj
@@ -1190,6 +1241,11 @@ class MainActivity: FlutterFragmentActivity() {
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueDoc.lastModified() }
val coverCacheKey = buildLibraryCoverCacheKey(
audioDoc.uri.toString(),
audioLastModified,
)
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
if (tempAudioPath == null) { if (tempAudioPath == null) {
@@ -1208,11 +1264,12 @@ class MainActivity: FlutterFragmentActivity() {
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L } val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
val cueResultsJson = Gobackend.scanCueSheetForLibrary( val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
tempCuePath, tempCuePath,
tempDir, tempDir,
cueDoc.uri.toString(), cueDoc.uri.toString(),
cueLastModified cueLastModified,
coverCacheKey,
) )
val cueArray = JSONArray(cueResultsJson) val cueArray = JSONArray(cueResultsJson)
@@ -1264,13 +1321,19 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
val stableUri = doc.uri.toString()
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, lastModified)
val metadataObj = readAudioMetadataFromUri(
doc.uri,
name,
fallbackExt,
coverCacheKey,
)
if (metadataObj == null) { if (metadataObj == null) {
errors++ errors++
} else { } else {
try { try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri)) metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri) metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", lastModified) metadataObj.put("fileModTime", lastModified)
@@ -1538,6 +1601,11 @@ class MainActivity: FlutterFragmentActivity() {
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null val fallbackAudioExt = if (audioExt.isNotBlank()) ".$audioExt" else null
val audioLastModified = try { audioDoc.lastModified() } catch (_: Exception) { cueLastModified }
val coverCacheKey = buildLibraryCoverCacheKey(
audioDoc.uri.toString(),
audioLastModified,
)
tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt) tempAudioPath = copyUriToTemp(audioDoc.uri, fallbackAudioExt)
if (tempAudioPath == null) { if (tempAudioPath == null) {
@@ -1554,11 +1622,12 @@ class MainActivity: FlutterFragmentActivity() {
tempAudioPath = renamedAudio.absolutePath tempAudioPath = renamedAudio.absolutePath
} }
val cueResultsJson = Gobackend.scanCueSheetForLibrary( val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
tempCuePath, tempCuePath,
tempDir, tempDir,
cueDoc.uri.toString(), cueDoc.uri.toString(),
cueLastModified cueLastModified,
coverCacheKey,
) )
val cueArray = JSONArray(cueResultsJson) val cueArray = JSONArray(cueResultsJson)
@@ -1655,13 +1724,19 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null
val metadataObj = readAudioMetadataFromUri(doc.uri, name, fallbackExt) val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
val stableUri = doc.uri.toString()
val coverCacheKey = buildLibraryCoverCacheKey(stableUri, safeLastModified)
val metadataObj = readAudioMetadataFromUri(
doc.uri,
name,
fallbackExt,
coverCacheKey,
)
if (metadataObj == null) { if (metadataObj == null) {
errors++ errors++
} else { } else {
try { try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri)) metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri) metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", safeLastModified) metadataObj.put("fileModTime", safeLastModified)
@@ -1941,13 +2016,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(null) result.success(null)
} }
"parseSpotifyUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseSpotifyURL(url)
}
result.success(response)
}
"checkAvailability" -> { "checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: "" val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: "" val isrc = call.argument<String>("isrc") ?: ""
@@ -2367,8 +2435,10 @@ class MainActivity: FlutterFragmentActivity() {
} }
// FLAC: Go wrote directly to temp, copy back now // FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) { if (!writeUriFromPath(uri, tempPath)) {
try { File(tempPath).delete() } catch (_: Exception) {}
return@withContext """{"error":"Failed to write metadata back to SAF file"}""" return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
} }
try { File(tempPath).delete() } catch (_: Exception) {}
raw raw
} catch (e: Exception) { } catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {} try { File(tempPath).delete() } catch (_: Exception) {}
@@ -2711,13 +2781,6 @@ class MainActivity: FlutterFragmentActivity() {
} }
result.success(response) result.success(response)
} }
"getSpotifyMetadataWithFallback" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> { "checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: "" val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) { val response = withContext(Dispatchers.IO) {
+26 -3
View File
@@ -980,6 +980,8 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
} }
reader := bytes.NewReader(data) reader := bytes.NewReader(data)
artistValues := make([]string, 0, 1)
albumArtistValues := make([]string, 0, 1)
// Read vendor string length // Read vendor string length
var vendorLen uint32 var vendorLen uint32
@@ -1034,9 +1036,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "TITLE": case "TITLE":
metadata.Title = value metadata.Title = value
case "ARTIST": case "ARTIST":
metadata.Artist = value artistValues = append(artistValues, value)
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST": case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
metadata.AlbumArtist = value albumArtistValues = append(albumArtistValues, value)
case "ALBUM": case "ALBUM":
metadata.Album = value metadata.Album = value
case "DATE", "YEAR": case "DATE", "YEAR":
@@ -1066,6 +1068,13 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.Copyright = value metadata.Copyright = value
} }
} }
if len(artistValues) > 0 {
metadata.Artist = joinVorbisCommentValues(artistValues)
}
if len(albumArtistValues) > 0 {
metadata.AlbumArtist = joinVorbisCommentValues(albumArtistValues)
}
} }
func GetOggQuality(filePath string) (*OggQuality, error) { func GetOggQuality(filePath string) (*OggQuality, error) {
@@ -1620,14 +1629,28 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
} }
func SaveCoverToCache(filePath, cacheDir string) (string, error) { func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return SaveCoverToCacheWithHint(filePath, "", cacheDir) return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
} }
func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) { func SaveCoverToCacheWithHint(filePath, displayNameHint, cacheDir string) (string, error) {
return SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, "")
}
func resolveLibraryCoverCacheKey(filePath, explicitKey string) string {
explicitKey = strings.TrimSpace(explicitKey)
if explicitKey != "" {
return explicitKey
}
cacheKey := filePath cacheKey := filePath
if stat, err := os.Stat(filePath); err == nil { if stat, err := os.Stat(filePath); err == nil {
cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano()) cacheKey = fmt.Sprintf("%s|%d|%d", filePath, stat.Size(), stat.ModTime().UnixNano())
} }
return cacheKey
}
func SaveCoverToCacheWithHintAndKey(filePath, displayNameHint, cacheDir, coverCacheKey string) (string, error) {
cacheKey := resolveLibraryCoverCacheKey(filePath, coverCacheKey)
hash := hashString(cacheKey) hash := hashString(cacheKey)
jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash)) jpgPath := filepath.Join(cacheDir, fmt.Sprintf("cover_%x.jpg", hash))
+34
View File
@@ -0,0 +1,34 @@
package gobackend
import (
"os"
"strings"
"testing"
)
func TestResolveLibraryCoverCacheKeyUsesExplicitKey(t *testing.T) {
t.Parallel()
const explicitKey = "content://media/external/audio/media/42|123456"
got := resolveLibraryCoverCacheKey("/tmp/saf_random.flac", explicitKey)
if got != explicitKey {
t.Fatalf("expected explicit cache key %q, got %q", explicitKey, got)
}
}
func TestResolveLibraryCoverCacheKeyUsesFilePathAndStatWhenNoExplicitKey(t *testing.T) {
t.Parallel()
tempFile, err := os.CreateTemp("", "cover-cache-*.flac")
if err != nil {
t.Fatalf("CreateTemp failed: %v", err)
}
tempPath := tempFile.Name()
tempFile.Close()
defer os.Remove(tempPath)
got := resolveLibraryCoverCacheKey(tempPath, "")
if !strings.HasPrefix(got, tempPath+"|") {
t.Fatalf("expected stat-based cache key to start with %q, got %q", tempPath+"|", got)
}
}
+28 -4
View File
@@ -422,7 +422,7 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
if err != nil { if err != nil {
return nil, err return nil, err
} }
return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, scanTime) return scanCueSheetForLibrary(cuePath, sheet, audioPath, "", 0, "", scanTime)
} }
// ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters // ScanCueFileForLibraryExt is like ScanCueFileForLibrary but with extra parameters
@@ -433,6 +433,17 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
// - fileModTime: if > 0, used as the FileModTime for all results instead of // - fileModTime: if > 0, used as the FileModTime for all results instead of
// stat-ing the cuePath on disk (useful when the real file lives behind SAF) // stat-ing the cuePath on disk (useful when the real file lives behind SAF)
func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) {
return ScanCueFileForLibraryExtWithCoverCacheKey(
cuePath,
audioDir,
virtualPathPrefix,
fileModTime,
"",
scanTime,
)
}
func ScanCueFileForLibraryExtWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
sheet, err := ParseCueFile(cuePath) sheet, err := ParseCueFile(cuePath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -441,7 +452,15 @@ func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileM
if err != nil { if err != nil {
return nil, err return nil, err
} }
return scanCueSheetForLibrary(cuePath, sheet, audioPath, virtualPathPrefix, fileModTime, scanTime) return scanCueSheetForLibrary(
cuePath,
sheet,
audioPath,
virtualPathPrefix,
fileModTime,
coverCacheKey,
scanTime,
)
} }
func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) { func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir string) (string, error) {
@@ -459,7 +478,7 @@ func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir str
return audioPath, nil return audioPath, nil
} }
func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, scanTime string) ([]LibraryScanResult, error) { func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualPathPrefix string, fileModTime int64, coverCacheKey, scanTime string) ([]LibraryScanResult, error) {
if sheet == nil { if sheet == nil {
return nil, fmt.Errorf("cue sheet is nil for %s", cuePath) return nil, fmt.Errorf("cue sheet is nil for %s", cuePath)
} }
@@ -492,7 +511,12 @@ func scanCueSheetForLibrary(cuePath string, sheet *CueSheet, audioPath, virtualP
coverCacheDir := libraryCoverCacheDir coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock() libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" { if coverCacheDir != "" {
cp, err := SaveCoverToCache(audioPath, coverCacheDir) cp, err := SaveCoverToCacheWithHintAndKey(
audioPath,
"",
coverCacheDir,
coverCacheKey,
)
if err == nil && cp != "" { if err == nil && cp != "" {
coverPath = cp coverPath = cp
} }
+7 -4
View File
@@ -204,7 +204,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
} }
if deezerID != "" { if deezerID != "" {
trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID) trackURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerID)
if err := verifyDeezerTrack(req, deezerID); err != nil { if err := verifyDeezerTrack(req, deezerID, false); err != nil {
GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err) GoLog("[Deezer] Direct ID %s verification failed: %v\n", deezerID, err)
// Don't reject direct IDs from request payload — they're presumably correct. // Don't reject direct IDs from request payload — they're presumably correct.
} }
@@ -219,7 +219,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
if err == nil && availability.Deezer && availability.DeezerURL != "" { if err == nil && availability.Deezer && availability.DeezerURL != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL) resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
if resolvedID != "" { if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { if verifyErr := verifyDeezerTrack(req, resolvedID, true); verifyErr != nil {
GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr) GoLog("[Deezer] SongLink ID %s rejected: %v\n", resolvedID, verifyErr)
// Fall through to ISRC search instead of using wrong track. // Fall through to ISRC search instead of using wrong track.
} else { } else {
@@ -240,7 +240,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
if err == nil && track != nil { if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track) resolvedID := songLinkExtractDeezerTrackID(track)
if resolvedID != "" { if resolvedID != "" {
if verifyErr := verifyDeezerTrack(req, resolvedID); verifyErr != nil { if verifyErr := verifyDeezerTrack(req, resolvedID, false); verifyErr != nil {
GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr) GoLog("[Deezer] ISRC-resolved ID %s rejected: %v\n", resolvedID, verifyErr)
return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr) return "", fmt.Errorf("deezer track resolved via ISRC does not match: %w", verifyErr)
} }
@@ -252,7 +252,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
return "", fmt.Errorf("could not resolve Deezer track URL") return "", fmt.Errorf("could not resolve Deezer track URL")
} }
func verifyDeezerTrack(req DownloadRequest, deezerID string) error { func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel() defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID) trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
@@ -262,7 +262,9 @@ func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
resolved := resolvedTrackInfo{ resolved := resolvedTrackInfo{
Title: trackResp.Track.Name, Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists, ArtistName: trackResp.Track.Artists,
ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000, Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
} }
if !trackMatchesRequest(req, resolved, "Deezer") { if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'", return fmt.Errorf("expected '%s - %s', got '%s - %s'",
@@ -526,6 +528,7 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate, Date: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
+352 -163
View File
@@ -13,25 +13,6 @@ import (
"github.com/dop251/goja" "github.com/dop251/goja"
) )
func ParseSpotifyURL(url string) (string, error) {
parsed, err := parseSpotifyURI(url)
if err != nil {
return "", err
}
result := map[string]string{
"type": parsed.Type,
"id": parsed.ID,
}
jsonBytes, err := json.Marshal(result)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func CheckAvailability(spotifyID, isrc string) (string, error) { func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc) availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -68,6 +49,7 @@ type DownloadRequest struct {
FilenameFormat string `json:"filename_format"` FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"` Quality string `json:"quality"`
EmbedMetadata bool `json:"embed_metadata"` EmbedMetadata bool `json:"embed_metadata"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
EmbedLyrics bool `json:"embed_lyrics"` EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"` EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
TrackNumber int `json:"track_number"` TrackNumber int `json:"track_number"`
@@ -135,6 +117,314 @@ type DownloadResult struct {
DecryptionKey string DecryptionKey string
} }
type reEnrichRequest struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
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"`
}
func applyReEnrichTrackMetadata(req *reEnrichRequest, track ExtTrackMetadata) {
if req == nil {
return
}
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else if track.ID != "" {
req.SpotifyID = track.ID
}
if track.AlbumName != "" {
req.AlbumName = track.AlbumName
}
if track.AlbumArtist != "" {
req.AlbumArtist = track.AlbumArtist
}
if track.TrackNumber > 0 {
req.TrackNumber = track.TrackNumber
}
if track.DiscNumber > 0 {
req.DiscNumber = track.DiscNumber
}
if track.ReleaseDate != "" {
req.ReleaseDate = track.ReleaseDate
}
if track.ISRC != "" {
req.ISRC = track.ISRC
}
if coverURL := track.ResolvedCoverURL(); coverURL != "" {
req.CoverURL = coverURL
}
if track.DurationMS > 0 {
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
}
}
func reEnrichDownloadRequest(req reEnrichRequest) DownloadRequest {
return DownloadRequest{
TrackName: req.TrackName,
ArtistName: req.ArtistName,
AlbumName: req.AlbumName,
ReleaseDate: req.ReleaseDate,
ISRC: req.ISRC,
DurationMS: int(req.DurationMs),
ArtistTagMode: req.ArtistTagMode,
}
}
func buildReEnrichFFmpegMetadata(req reEnrichRequest, lyricsLRC string) map[string]string {
metadata := map[string]string{}
if req.TrackName != "" {
metadata["TITLE"] = req.TrackName
}
if req.ArtistName != "" {
metadata["ARTIST"] = req.ArtistName
}
if req.AlbumName != "" {
metadata["ALBUM"] = req.AlbumName
}
if req.AlbumArtist != "" {
metadata["ALBUMARTIST"] = req.AlbumArtist
}
if req.ReleaseDate != "" {
metadata["DATE"] = req.ReleaseDate
}
if req.ISRC != "" {
metadata["ISRC"] = req.ISRC
}
if req.Genre != "" {
metadata["GENRE"] = req.Genre
}
if req.TrackNumber > 0 {
metadata["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
}
if req.DiscNumber > 0 {
metadata["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
}
if req.Label != "" {
metadata["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
metadata["COPYRIGHT"] = req.Copyright
}
if lyricsLRC != "" {
metadata["LYRICS"] = lyricsLRC
metadata["UNSYNCEDLYRICS"] = lyricsLRC
}
return metadata
}
func selectBestReEnrichTrack(req reEnrichRequest, tracks []ExtTrackMetadata) *ExtTrackMetadata {
if len(tracks) == 0 {
return nil
}
downloadReq := reEnrichDownloadRequest(req)
currentISRC := strings.TrimSpace(req.ISRC)
currentAlbum := strings.TrimSpace(req.AlbumName)
var best *ExtTrackMetadata
bestScore := -1 << 30
for i := range tracks {
track := &tracks[i]
score := 0
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
score += 2000
}
if currentISRC != "" && strings.EqualFold(currentISRC, strings.TrimSpace(track.ISRC)) {
score += 10000
}
if req.TrackName != "" && track.Name != "" && titlesMatch(req.TrackName, track.Name) {
score += 400
}
if req.ArtistName != "" && track.Artists != "" && artistsMatch(req.ArtistName, track.Artists) {
score += 320
}
if currentAlbum != "" && track.AlbumName != "" {
switch {
case titlesMatch(currentAlbum, track.AlbumName):
score += 120
case strings.Contains(strings.ToLower(track.AlbumName), strings.ToLower(currentAlbum)),
strings.Contains(strings.ToLower(currentAlbum), strings.ToLower(track.AlbumName)):
score += 50
}
}
if req.DurationMs > 0 && track.DurationMS > 0 {
diff := int(req.DurationMs/1000) - (track.DurationMS / 1000)
if diff < 0 {
diff = -diff
}
if diff <= 10 {
score += 80
}
}
if track.ReleaseDate != "" {
score += 70
}
if track.TrackNumber > 0 {
score += 20
}
if track.DiscNumber > 0 {
score += 10
}
if track.ISRC != "" {
score += 40
}
if best == nil || score > bestScore {
best = track
bestScore = score
}
}
return best
}
func extTrackFromTrackMetadata(track *TrackMetadata, providerID string) *ExtTrackMetadata {
if track == nil {
return nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(track.SpotifyID, "deezer:"))
return &ExtTrackMetadata{
ID: track.SpotifyID,
Name: track.Name,
Artists: track.Artists,
AlbumName: track.AlbumName,
AlbumArtist: track.AlbumArtist,
DurationMS: track.DurationMS,
CoverURL: track.Images,
Images: track.Images,
ReleaseDate: track.ReleaseDate,
TrackNumber: track.TrackNumber,
DiscNumber: track.DiscNumber,
ISRC: track.ISRC,
ProviderID: providerID,
DeezerID: deezerID,
SpotifyID: track.SpotifyID,
}
}
func normalizeReEnrichSpotifyTrackID(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
if extracted := extractSpotifyIDFromURL(trimmed); extracted != "" {
return extracted
}
if len(trimmed) == 22 && !strings.Contains(trimmed, ":") && !strings.Contains(trimmed, "/") {
return trimmed
}
return ""
}
func resolveReEnrichTrackFromIdentifiers(req reEnrichRequest) (*ExtTrackMetadata, error) {
deezerClient := GetDeezerClient()
downloadReq := reEnrichDownloadRequest(req)
if isrc := strings.TrimSpace(req.ISRC); isrc != "" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
track, err := deezerClient.SearchByISRC(ctx, isrc)
cancel()
if err == nil && track != nil {
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return extTrackFromTrackMetadata(track, "deezer"), nil
}
}
}
sourceTrackID := strings.TrimSpace(req.SpotifyID)
if sourceTrackID == "" {
return nil, nil
}
deezerID := strings.TrimSpace(strings.TrimPrefix(sourceTrackID, "deezer:"))
if deezerID == sourceTrackID {
deezerID = extractDeezerIDFromURL(sourceTrackID)
}
if deezerID == "" {
spotifyID := normalizeReEnrichSpotifyTrackID(sourceTrackID)
if spotifyID != "" {
resolvedDeezerID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyID)
if err == nil {
deezerID = strings.TrimSpace(resolvedDeezerID)
}
}
}
if deezerID == "" {
return nil, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
trackResp, err := deezerClient.GetTrack(ctx, deezerID)
if err != nil || trackResp == nil {
return nil, err
}
track := &trackResp.Track
resolved := resolvedTrackInfo{
Title: track.Name,
ArtistName: track.Artists,
ISRC: track.ISRC,
Duration: track.DurationMS / 1000,
}
if !trackMatchesRequest(downloadReq, resolved, "ReEnrich") {
return nil, nil
}
return extTrackFromTrackMetadata(track, "deezer"), nil
}
func preferredReleaseMetadata( func preferredReleaseMetadata(
req DownloadRequest, req DownloadRequest,
album string, album string,
@@ -864,6 +1154,26 @@ func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileMod
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func ScanCueSheetForLibraryWithCoverCacheKey(cuePath, audioDir, virtualPathPrefix string, fileModTime int64, coverCacheKey string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339)
results, err := ScanCueFileForLibraryExtWithCoverCacheKey(
cuePath,
audioDir,
virtualPathPrefix,
fileModTime,
coverCacheKey,
scanTime,
)
if err != nil {
return "[]", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "[]", fmt.Errorf("failed to marshal cue scan results: %w", err)
}
return string(jsonBytes), nil
}
// EditFileMetadata writes metadata to an audio file. // EditFileMetadata writes metadata to an audio file.
// For FLAC files, uses native Go FLAC library. // For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg. // For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
@@ -892,6 +1202,7 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
Artist: fields["artist"], Artist: fields["artist"],
Album: fields["album"], Album: fields["album"],
AlbumArtist: fields["album_artist"], AlbumArtist: fields["album_artist"],
ArtistTagMode: fields["artist_tag_mode"],
Date: fields["date"], Date: fields["date"],
TrackNumber: trackNum, TrackNumber: trackNum,
DiscNumber: discNum, DiscNumber: discNum,
@@ -1526,72 +1837,6 @@ func ConvertSpotifyToDeezer(resourceType, spotifyID string) (string, error) {
return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType) return "", fmt.Errorf("Spotify to Deezer conversion only supported for tracks and albums. Please search by name for %s", resourceType)
} }
func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
jsonBytes, err := json.Marshal(spotFetchData)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
}
GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" {
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
}
if parsed.Type == "artist" {
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
}
func shouldTrySpotFetchFallback(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrNoSpotifyCredentials) {
return true
}
errStr := strings.ToLower(err.Error())
indicators := []string{
"429",
"rate",
"limit",
"403",
"forbidden",
"401",
"unauthorized",
"timeout",
"connection",
"spotify error",
"access token",
"client token",
"eof",
}
for _, indicator := range indicators {
if strings.Contains(errStr, indicator) {
return true
}
}
return false
}
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {
client := NewSongLinkClient() client := NewSongLinkClient()
availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID) availability, err := client.CheckAvailabilityFromDeezer(deezerTrackID)
@@ -1801,26 +2046,7 @@ func GetLyricsFetchOptionsJSON() (string, error) {
// When search_online is true, searches Spotify/Deezer by track name + artist to fetch // When search_online is true, searches Spotify/Deezer by track name + artist to fetch
// complete metadata from the internet before embedding. // complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) { func ReEnrichFile(requestJSON string) (string, error) {
var req struct { var req reEnrichRequest
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 { if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err) return "", fmt.Errorf("failed to parse request: %w", err)
@@ -1842,42 +2068,22 @@ func ReEnrichFile(requestJSON string) (string, error) {
deezerClient := GetDeezerClient() deezerClient := GetDeezerClient()
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n") GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
manager := GetExtensionManager() manager := GetExtensionManager()
if identifierTrack, err := resolveReEnrichTrackFromIdentifiers(req); err == nil && identifierTrack != nil {
GoLog("[ReEnrich] Identifier-first metadata match (%s): %s - %s (album: %s, date: %s)\n",
identifierTrack.ProviderID, identifierTrack.Name, identifierTrack.Artists, identifierTrack.AlbumName, identifierTrack.ReleaseDate)
applyReEnrichTrackMetadata(&req, *identifierTrack)
found = true
}
tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true) tracks, searchErr := manager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
if searchErr == nil && len(tracks) > 0 { if searchErr == nil && len(tracks) > 0 {
track := tracks[0] track := selectBestReEnrichTrack(req, tracks)
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName) if track != nil {
if track.SpotifyID != "" { GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
req.SpotifyID = track.SpotifyID track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
} else if track.DeezerID != "" { applyReEnrichTrackMetadata(&req, *track)
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} 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 found = true
}
} else if searchErr != nil { } else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr) GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
} }
@@ -2012,6 +2218,7 @@ func ReEnrichFile(requestJSON string) (string, error) {
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate, Date: req.ReleaseDate,
TrackNumber: req.TrackNumber, TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber, DiscNumber: req.DiscNumber,
@@ -2055,36 +2262,14 @@ func ReEnrichFile(requestJSON string) (string, error) {
// Don't cleanup cover temp — Dart needs it for FFmpeg embed // Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false cleanupCover = false
ffmpegMetadata := buildReEnrichFFmpegMetadata(req, lyricsLRC)
result := map[string]interface{}{ result := map[string]interface{}{
"method": "ffmpeg", "method": "ffmpeg",
"cover_path": coverTempPath, "cover_path": coverTempPath,
"lyrics": lyricsLRC, "lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta, "enriched_metadata": enrichedMeta,
"metadata": map[string]string{ "metadata": ffmpegMetadata,
"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) jsonBytes, _ := json.Marshal(result)
@@ -3328,3 +3513,7 @@ func ReadAudioMetadataJSON(filePath string) (string, error) {
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) { func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
return ReadAudioMetadataWithDisplayName(filePath, displayName) return ReadAudioMetadataWithDisplayName(filePath, displayName)
} }
func ReadAudioMetadataWithHintAndCoverCacheKeyJSON(filePath, displayName, coverCacheKey string) (string, error) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayName, coverCacheKey)
}
+109
View File
@@ -113,3 +113,112 @@ func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL) t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
} }
} }
func TestApplyReEnrichTrackMetadataPreservesExistingReleaseDateWhenCandidateMissing(t *testing.T) {
req := reEnrichRequest{
SpotifyID: "spotify-track-id",
AlbumName: "Original Album",
ReleaseDate: "2024-01-01",
ISRC: "REQ123",
}
applyReEnrichTrackMetadata(&req, ExtTrackMetadata{
AlbumName: "Resolved Album",
ReleaseDate: "",
ISRC: "",
})
if req.ReleaseDate != "2024-01-01" {
t.Fatalf("release date = %q, want existing value preserved", req.ReleaseDate)
}
if req.AlbumName != "Resolved Album" {
t.Fatalf("album = %q, want updated album", req.AlbumName)
}
if req.ISRC != "REQ123" {
t.Fatalf("isrc = %q, want existing value preserved", req.ISRC)
}
}
func TestSelectBestReEnrichTrackPrefersCandidateWithReleaseDate(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song Title",
ArtistName: "Artist Name",
AlbumName: "Album Name",
ReleaseDate: "",
DurationMs: 180000,
}
tracks := []ExtTrackMetadata{
{
ID: "first",
Name: "Song Title",
Artists: "Artist Name",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "",
ProviderID: "spotify",
},
{
ID: "second",
Name: "Song Title",
Artists: "Artist Name",
AlbumName: "Album Name",
DurationMS: 180000,
ReleaseDate: "2024-03-09",
ProviderID: "deezer",
},
}
best := selectBestReEnrichTrack(req, tracks)
if best == nil {
t.Fatal("expected a selected track")
}
if best.ID != "second" {
t.Fatalf("selected track = %q, want candidate with release date", best.ID)
}
}
func TestBuildReEnrichFFmpegMetadataOmitsEmptyFields(t *testing.T) {
req := reEnrichRequest{
TrackName: "Song",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "",
ReleaseDate: "",
TrackNumber: 0,
DiscNumber: 0,
ISRC: "",
Genre: "",
Label: "",
Copyright: "",
}
metadata := buildReEnrichFFmpegMetadata(req, "")
if metadata["TITLE"] != "Song" {
t.Fatalf("title = %q", metadata["TITLE"])
}
if metadata["ARTIST"] != "Artist" {
t.Fatalf("artist = %q", metadata["ARTIST"])
}
if metadata["ALBUM"] != "Album" {
t.Fatalf("album = %q", metadata["ALBUM"])
}
for _, key := range []string{
"ALBUMARTIST",
"DATE",
"TRACKNUMBER",
"DISCNUMBER",
"ISRC",
"GENRE",
"ORGANIZATION",
"COPYRIGHT",
"LYRICS",
"UNSYNCEDLYRICS",
} {
if _, exists := metadata[key]; exists {
t.Fatalf("did not expect key %s in metadata: %#v", key, metadata)
}
}
}
+43 -15
View File
@@ -32,6 +32,7 @@ type LibraryScanResult struct {
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis) Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"` Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"` Format string `json:"format,omitempty"`
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
} }
type LibraryScanProgress struct { type LibraryScanProgress struct {
@@ -219,6 +220,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
cueInfo.audioPath, cueInfo.audioPath,
"", "",
fileInfo.modTime, fileInfo.modTime,
"",
scanTime, scanTime,
) )
} else { } else {
@@ -269,10 +271,14 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) {
} }
func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) { func scanAudioFileWithKnownModTime(filePath, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayName(filePath, "", scanTime, knownModTime) return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, "", "", scanTime, knownModTime)
} }
func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) { func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint) ext := resolveLibraryAudioExt(filePath, displayNameHint)
result := &LibraryScanResult{ result := &LibraryScanResult{
@@ -292,7 +298,12 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
coverCacheDir := libraryCoverCacheDir coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock() libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" { if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir) coverPath, err := SaveCoverToCacheWithHintAndKey(
filePath,
displayNameHint,
coverCacheDir,
coverCacheKey,
)
if err == nil && coverPath != "" { if err == nil && coverPath != "" {
result.CoverPath = coverPath result.CoverPath = coverPath
} }
@@ -300,11 +311,11 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
switch ext { switch ext {
case ".flac": case ".flac":
return scanFLACFile(filePath, result) return scanFLACFile(filePath, result, displayNameHint)
case ".m4a": case ".m4a":
return scanM4AFile(filePath, result) return scanM4AFile(filePath, result, displayNameHint)
case ".mp3": case ".mp3":
return scanMP3File(filePath, result) return scanMP3File(filePath, result, displayNameHint)
case ".opus", ".ogg": case ".opus", ".ogg":
return scanOggFile(filePath, result, displayNameHint) return scanOggFile(filePath, result, displayNameHint)
default: default:
@@ -340,10 +351,10 @@ func applyDefaultLibraryMetadata(filePath, displayNameHint string, result *Libra
} }
} }
func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanFLACFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadMetadata(filePath) metadata, err := ReadMetadata(filePath)
if err != nil { if err != nil {
return scanFromFilename(filePath, "", result) return scanFromFilename(filePath, displayNameHint, result)
} }
result.TrackName = metadata.Title result.TrackName = metadata.Title
@@ -365,14 +376,19 @@ func scanFLACFile(filePath string, result *LibraryScanResult) (*LibraryScanResul
} }
} }
applyDefaultLibraryMetadata(filePath, "", result) applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil return result, nil
} }
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadM4ATags(filePath) metadata, err := ReadM4ATags(filePath)
if err == nil && metadata != nil { if err != nil {
GoLog("[LibraryScan] M4A read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, displayNameHint, result)
}
if metadata != nil {
result.TrackName = metadata.Title result.TrackName = metadata.Title
result.ArtistName = metadata.Artist result.ArtistName = metadata.Artist
result.AlbumName = metadata.Album result.AlbumName = metadata.Album
@@ -393,15 +409,15 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
result.SampleRate = quality.SampleRate result.SampleRate = quality.SampleRate
} }
applyDefaultLibraryMetadata(filePath, "", result) applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil return result, nil
} }
func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanMP3File(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
metadata, err := ReadID3Tags(filePath) metadata, err := ReadID3Tags(filePath)
if err != nil { if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err) GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, "", result) return scanFromFilename(filePath, displayNameHint, result)
} }
result.TrackName = metadata.Title result.TrackName = metadata.Title
@@ -428,7 +444,7 @@ func scanMP3File(filePath string, result *LibraryScanResult) (*LibraryScanResult
} }
} }
applyDefaultLibraryMetadata(filePath, "", result) applyDefaultLibraryMetadata(filePath, displayNameHint, result)
return result, nil return result, nil
} }
@@ -466,6 +482,7 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
} }
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) { func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
result.MetadataFromFilename = true
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint) nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource)) filename := strings.TrimSuffix(filepath.Base(nameSource), filepath.Ext(nameSource))
@@ -541,8 +558,18 @@ func ReadAudioMetadata(filePath string) (string, error) {
} }
func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) { func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, error) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
}
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
scanTime := time.Now().UTC().Format(time.RFC3339) scanTime := time.Now().UTC().Format(time.RFC3339)
result, err := scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scanTime, 0) result, err := scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(
filePath,
displayNameHint,
coverCacheKey,
scanTime,
0,
)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -746,6 +773,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
cueInfo.audioPath, cueInfo.audioPath,
"", "",
f.modTime, f.modTime,
"",
scanTime, scanTime,
) )
} else { } else {
+25
View File
@@ -0,0 +1,25 @@
package gobackend
import "testing"
func TestScanFromFilenameMarksMetadataFallback(t *testing.T) {
result := &LibraryScanResult{}
scanned, err := scanFromFilename(
"/proc/self/fd/209",
"189.mp3",
result,
)
if err != nil {
t.Fatalf("scanFromFilename returned error: %v", err)
}
if !scanned.MetadataFromFilename {
t.Fatal("expected filename fallback marker to be set")
}
if scanned.TrackName != "189" {
t.Fatalf("unexpected track name: %q", scanned.TrackName)
}
if scanned.ArtistName != "Unknown Artist" {
t.Fatalf("unexpected artist name: %q", scanned.ArtistName)
}
}
-10
View File
@@ -25,7 +25,6 @@ type LogBuffer struct {
const ( const (
defaultLogBufferSize = 500 defaultLogBufferSize = 500
maxLogMessageLength = 500
) )
var ( var (
@@ -58,14 +57,6 @@ func GetLogBuffer() *LogBuffer {
return globalLogBuffer return globalLogBuffer
} }
func truncateLogMessage(message string) string {
runes := []rune(message)
if len(runes) <= maxLogMessageLength {
return message
}
return string(runes[:maxLogMessageLength]) + "...[truncated]"
}
func (lb *LogBuffer) SetLoggingEnabled(enabled bool) { func (lb *LogBuffer) SetLoggingEnabled(enabled bool) {
lb.mu.Lock() lb.mu.Lock()
defer lb.mu.Unlock() defer lb.mu.Unlock()
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
} }
message = sanitizeSensitiveLogText(message) message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message)
entry := LogEntry{ entry := LogEntry{
Timestamp: time.Now().Format("15:04:05.000"), Timestamp: time.Now().Format("15:04:05.000"),
+1 -1
View File
@@ -982,7 +982,7 @@ func convertToLRCWithMetadata(lyrics *LyricsResponse, trackName, artistName stri
builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName)) builder.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName)) builder.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n") builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
builder.WriteString("\n") builder.WriteString("\n")
if lyrics.SyncType == "LINE_SYNCED" { if lyrics.SyncType == "LINE_SYNCED" {
+111 -8
View File
@@ -11,6 +11,7 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv" "strconv"
"strings" "strings"
@@ -19,6 +20,10 @@ import (
"github.com/go-flac/go-flac/v2" "github.com/go-flac/go-flac/v2"
) )
const artistTagModeSplitVorbis = "split_vorbis"
var artistTagSplitPattern = regexp.MustCompile(`\s*(?:,|&|\bx\b)\s*|\s+\b(?:feat(?:uring)?|ft|with)\.?\s*`)
func detectCoverMIME(coverPath string, coverData []byte) string { func detectCoverMIME(coverPath string, coverData []byte) string {
// Prefer magic-byte detection over file extension. // Prefer magic-byte detection over file extension.
// Some providers return non-JPEG data behind .jpg URLs. // Some providers return non-JPEG data behind .jpg URLs.
@@ -100,6 +105,7 @@ type Metadata struct {
Artist string Artist string
Album string Album string
AlbumArtist string AlbumArtist string
ArtistTagMode string
Date string Date string
TrackNumber int TrackNumber int
TotalTracks int TotalTracks int
@@ -139,9 +145,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
} }
setComment(cmt, "TITLE", metadata.Title) setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist) setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
setComment(cmt, "ALBUM", metadata.Album) setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) setArtistComments(
cmt,
"ALBUMARTIST",
metadata.AlbumArtist,
metadata.ArtistTagMode,
)
setComment(cmt, "DATE", metadata.Date) setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
@@ -248,9 +259,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
} }
setComment(cmt, "TITLE", metadata.Title) setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist) setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
setComment(cmt, "ALBUM", metadata.Album) setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist) setArtistComments(
cmt,
"ALBUMARTIST",
metadata.AlbumArtist,
metadata.ArtistTagMode,
)
setComment(cmt, "DATE", metadata.Date) setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 { if metadata.TrackNumber > 0 {
@@ -339,9 +355,9 @@ func ReadMetadata(filePath string) (*Metadata, error) {
} }
metadata.Title = getComment(cmt, "TITLE") metadata.Title = getComment(cmt, "TITLE")
metadata.Artist = getComment(cmt, "ARTIST") metadata.Artist = getJoinedComment(cmt, "ARTIST")
metadata.Album = getComment(cmt, "ALBUM") metadata.Album = getComment(cmt, "ALBUM")
metadata.AlbumArtist = getComment(cmt, "ALBUMARTIST") metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST")
metadata.Date = getComment(cmt, "DATE") metadata.Date = getComment(cmt, "DATE")
metadata.ISRC = getComment(cmt, "ISRC") metadata.ISRC = getComment(cmt, "ISRC")
metadata.Description = getComment(cmt, "DESCRIPTION") metadata.Description = getComment(cmt, "DESCRIPTION")
@@ -394,6 +410,28 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" { if value == "" {
return return
} }
removeCommentKey(cmt, key)
cmt.Comments = append(cmt.Comments, key+"="+value)
}
func setArtistComments(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value, mode string) {
values := []string{value}
if shouldSplitVorbisArtistTags(mode) {
values = splitArtistTagValues(value)
}
if len(values) == 0 {
return
}
removeCommentKey(cmt, key)
for _, artist := range values {
if strings.TrimSpace(artist) == "" {
continue
}
cmt.Comments = append(cmt.Comments, key+"="+artist)
}
}
func removeCommentKey(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) {
keyUpper := strings.ToUpper(key) keyUpper := strings.ToUpper(key)
for i := len(cmt.Comments) - 1; i >= 0; i-- { for i := len(cmt.Comments) - 1; i >= 0; i-- {
comment := cmt.Comments[i] comment := cmt.Comments[i]
@@ -405,20 +443,85 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
} }
} }
} }
cmt.Comments = append(cmt.Comments, key+"="+value)
} }
func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string { func getComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
values := getCommentValues(cmt, key)
if len(values) == 0 {
return ""
}
return values[0]
}
func getJoinedComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) string {
return joinVorbisCommentValues(getCommentValues(cmt, key))
}
func getCommentValues(cmt *flacvorbis.MetaDataBlockVorbisComment, key string) []string {
keyUpper := strings.ToUpper(key) + "=" keyUpper := strings.ToUpper(key) + "="
values := make([]string, 0, 1)
for _, comment := range cmt.Comments { for _, comment := range cmt.Comments {
if len(comment) > len(key) { if len(comment) > len(key) {
commentUpper := strings.ToUpper(comment[:len(key)+1]) commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper { if commentUpper == keyUpper {
return comment[len(key)+1:] values = append(values, comment[len(key)+1:])
} }
} }
} }
return values
}
func shouldSplitVorbisArtistTags(mode string) bool {
return strings.EqualFold(strings.TrimSpace(mode), artistTagModeSplitVorbis)
}
func splitArtistTagValues(rawArtists string) []string {
trimmed := strings.TrimSpace(rawArtists)
if trimmed == "" {
return nil
}
parts := artistTagSplitPattern.Split(trimmed, -1)
values := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
artist := strings.TrimSpace(part)
if artist == "" {
continue
}
key := strings.ToLower(artist)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
values = append(values, artist)
}
if len(values) > 0 {
return values
}
return []string{trimmed}
}
func joinVorbisCommentValues(values []string) string {
if len(values) == 0 {
return "" return ""
}
joined := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
joined = append(joined, trimmed)
}
return strings.Join(joined, ", ")
} }
func fileExists(path string) bool { func fileExists(path string) bool {
+67
View File
@@ -0,0 +1,67 @@
package gobackend
import (
"bytes"
"encoding/binary"
"slices"
"testing"
"github.com/go-flac/flacvorbis/v2"
)
func TestSplitArtistTagValues(t *testing.T) {
got := splitArtistTagValues("Artist A, Artist B feat. Artist C & Artist B")
want := []string{"Artist A", "Artist B", "Artist C"}
if !slices.Equal(got, want) {
t.Fatalf("splitArtistTagValues() = %#v, want %#v", got, want)
}
}
func TestSetArtistCommentsSplitVorbis(t *testing.T) {
cmt := flacvorbis.New()
setArtistComments(cmt, "ARTIST", "Artist A, Artist B", artistTagModeSplitVorbis)
got := getCommentValues(cmt, "ARTIST")
want := []string{"Artist A", "Artist B"}
if !slices.Equal(got, want) {
t.Fatalf("getCommentValues(ARTIST) = %#v, want %#v", got, want)
}
}
func TestParseVorbisCommentsJoinsRepeatedArtists(t *testing.T) {
metadata := &AudioMetadata{}
parseVorbisComments(
buildVorbisCommentPayload(
[]string{
"TITLE=Song",
"ARTIST=Artist A",
"ARTIST=Artist B",
"ALBUMARTIST=Album Artist A",
"ALBUMARTIST=Album Artist B",
},
),
metadata,
)
if metadata.Title != "Song" {
t.Fatalf("title = %q", metadata.Title)
}
if metadata.Artist != "Artist A, Artist B" {
t.Fatalf("artist = %q", metadata.Artist)
}
if metadata.AlbumArtist != "Album Artist A, Album Artist B" {
t.Fatalf("album artist = %q", metadata.AlbumArtist)
}
}
func buildVorbisCommentPayload(comments []string) []byte {
var buf bytes.Buffer
_ = binary.Write(&buf, binary.LittleEndian, uint32(len("spotiflac")))
buf.WriteString("spotiflac")
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comments)))
for _, comment := range comments {
_ = binary.Write(&buf, binary.LittleEndian, uint32(len(comment)))
buf.WriteString(comment)
}
return buf.Bytes()
}
+280 -14
View File
@@ -13,6 +13,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -1597,11 +1598,16 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName) return nil, fmt.Errorf("no matching track found for: %s - %s", artistName, trackName)
} }
func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string) bool { func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix, source string, skipNameVerification bool) bool {
if track == nil { if track == nil {
return false return false
} }
exactISRCMatch := req.ISRC != "" &&
track.ISRC != "" &&
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC))
if !exactISRCMatch && !skipNameVerification {
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n", GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.ArtistName, track.Performer.Name) logPrefix, source, req.ArtistName, track.Performer.Name)
@@ -1613,6 +1619,7 @@ func qobuzTrackMatchesRequest(req DownloadRequest, track *QobuzTrack, logPrefix,
logPrefix, source, req.TrackName, track.Title) logPrefix, source, req.TrackName, track.Title)
return false return false
} }
}
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
if expectedDurationSec > 0 && track.Duration > 0 { if expectedDurationSec > 0 && track.Duration > 0 {
@@ -1644,7 +1651,8 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return nil, fmt.Errorf("search failed: HTTP %d", resp.StatusCode) body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
} }
var result struct { var result struct {
@@ -1658,6 +1666,234 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
return result.Tracks.Items, nil return result.Tracks.Items, nil
} }
type qobuzTrackSearchCandidate struct {
score int
track QobuzTrack
}
func qobuzNormalizedSearchText(value string) string {
return normalizeLooseArtistName(value)
}
func qobuzSearchTokens(value string) []string {
normalized := qobuzNormalizedSearchText(value)
if normalized == "" {
return nil
}
parts := strings.Fields(normalized)
tokens := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
if len(part) < 2 {
continue
}
if _, ok := seen[part]; ok {
continue
}
seen[part] = struct{}{}
tokens = append(tokens, part)
}
return tokens
}
func qobuzScoreTrackSearchCandidate(query string, track *QobuzTrack) int {
if track == nil {
return 0
}
queryNorm := qobuzNormalizedSearchText(query)
if queryNorm == "" {
return 0
}
titleNorm := qobuzNormalizedSearchText(track.Title)
displayNorm := qobuzNormalizedSearchText(qobuzTrackDisplayTitle(track))
artistNorm := qobuzNormalizedSearchText(qobuzTrackArtistName(track))
albumNorm := qobuzNormalizedSearchText(strings.TrimSpace(track.Album.Title))
score := 0
if qobuzTitlesMatch(query, track.Title) || qobuzTitlesMatch(query, qobuzTrackDisplayTitle(track)) {
score += 900
}
switch {
case queryNorm == titleNorm, queryNorm == displayNorm:
score += 1200
case (titleNorm != "" && strings.Contains(titleNorm, queryNorm)) ||
(displayNorm != "" && strings.Contains(displayNorm, queryNorm)):
score += 420
case (titleNorm != "" && strings.Contains(queryNorm, titleNorm)) ||
(displayNorm != "" && strings.Contains(queryNorm, displayNorm)):
score += 260
}
if artistNorm != "" && strings.Contains(queryNorm, artistNorm) {
score += 180
}
if albumNorm != "" && strings.Contains(queryNorm, albumNorm) {
score += 100
}
for _, token := range qobuzSearchTokens(query) {
switch {
case strings.Contains(titleNorm, token), strings.Contains(displayNorm, token):
score += 180
case strings.Contains(artistNorm, token):
score += 70
case strings.Contains(albumNorm, token):
score += 35
}
}
if track.ISRC != "" {
score += 15
}
if track.MaximumBitDepth >= 24 {
score += 10
}
if track.MaximumSamplingRate >= 88.2 {
score += 10
}
return score
}
func selectQobuzTracksFromAlbumSearchResults(
query string,
limit int,
albumSummaries []qobuzAlbumDetails,
loadAlbum func(string) (*qobuzAlbumDetails, error),
) ([]QobuzTrack, error) {
if strings.TrimSpace(query) == "" {
return nil, fmt.Errorf("empty qobuz album-search fallback query")
}
if len(albumSummaries) == 0 {
return nil, fmt.Errorf("album search returned no albums")
}
candidates := make([]qobuzTrackSearchCandidate, 0, limit)
seenTrackIDs := make(map[int64]struct{})
for _, summary := range albumSummaries {
albumID := strings.TrimSpace(summary.ID)
if albumID == "" {
continue
}
album, err := loadAlbum(albumID)
if err != nil || album == nil {
continue
}
for i := range album.Tracks.Items {
track := album.Tracks.Items[i]
track.Album.ID = album.ID
track.Album.QobuzID = album.QobuzID
track.Album.Title = album.Title
track.Album.ReleaseDate = album.ReleaseDateOriginal
track.Album.TracksCount = album.TracksCount
track.Album.ProductType = album.ProductType
track.Album.ReleaseType = album.ReleaseType
track.Album.Artist.ID = album.Artist.ID
track.Album.Artist.Name = album.Artist.Name
track.Album.Artists = album.Artists
track.Album.Image = album.Image
if track.ID > 0 {
if _, ok := seenTrackIDs[track.ID]; ok {
continue
}
seenTrackIDs[track.ID] = struct{}{}
}
score := qobuzScoreTrackSearchCandidate(query, &track)
if score <= 0 {
continue
}
candidates = append(candidates, qobuzTrackSearchCandidate{
score: score,
track: track,
})
}
}
if len(candidates) == 0 {
return nil, fmt.Errorf("album-search fallback returned no scored track candidates")
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].score != candidates[j].score {
return candidates[i].score > candidates[j].score
}
if candidates[i].track.MaximumBitDepth != candidates[j].track.MaximumBitDepth {
return candidates[i].track.MaximumBitDepth > candidates[j].track.MaximumBitDepth
}
return candidates[i].track.ID < candidates[j].track.ID
})
if limit > 0 && len(candidates) > limit {
candidates = candidates[:limit]
}
tracks := make([]QobuzTrack, 0, len(candidates))
for _, candidate := range candidates {
tracks = append(tracks, candidate.track)
}
return tracks, nil
}
func (q *QobuzDownloader) searchQobuzTracksViaAlbumSearch(query string, limit int) ([]QobuzTrack, error) {
albumLimit := limit
if albumLimit < 3 {
albumLimit = 3
}
if albumLimit > 8 {
albumLimit = 8
}
searchURL := fmt.Sprintf(
"https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s",
url.QueryEscape(strings.TrimSpace(query)),
albumLimit,
q.appID,
)
req, err := http.NewRequest("GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := DoRequestWithUserAgent(q.client, req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("album search failed: HTTP %d (%s)", resp.StatusCode, strings.TrimSpace(string(body)))
}
var albumResp struct {
Albums struct {
Items []qobuzAlbumDetails `json:"items"`
} `json:"albums"`
}
if err := json.NewDecoder(resp.Body).Decode(&albumResp); err != nil {
return nil, err
}
return selectQobuzTracksFromAlbumSearchResults(
query,
limit,
albumResp.Albums.Items,
q.getAlbumDetails,
)
}
func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 { func extractQobuzTrackIDsFromStoreSearchHTML(body []byte) []int64 {
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1) matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
if len(matches) == 0 { if len(matches) == 0 {
@@ -1735,9 +1971,18 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
if len(apiTracks) > 0 { if len(apiTracks) > 0 {
return apiTracks, nil return apiTracks, nil
} }
GoLog("[Qobuz] API search returned 0 results for '%s', trying store fallback\n", query) GoLog("[Qobuz] API search returned 0 results for '%s', trying album-search fallback\n", query)
} else { } else {
GoLog("[Qobuz] API search failed for '%s': %v. Trying store fallback.\n", query, apiErr) GoLog("[Qobuz] API search failed for '%s': %v. Trying album-search fallback.\n", query, apiErr)
}
albumTracks, albumErr := q.searchQobuzTracksViaAlbumSearch(query, limit)
if albumErr == nil && len(albumTracks) > 0 {
GoLog("[Qobuz] Album-search fallback returned %d candidate tracks for '%s'\n", len(albumTracks), query)
return albumTracks, nil
}
if albumErr != nil {
GoLog("[Qobuz] Album-search fallback failed for '%s': %v. Trying store fallback.\n", query, albumErr)
} }
storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit) storeTracks, storeErr := q.searchQobuzTracksViaStore(query, limit)
@@ -1746,10 +1991,21 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
return storeTracks, nil return storeTracks, nil
} }
if apiErr != nil && storeErr != nil { if apiErr != nil && albumErr != nil && storeErr != nil {
return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr) return nil, fmt.Errorf(
"api search failed (%v); album-search fallback failed (%v); store fallback failed (%v)",
apiErr,
albumErr,
storeErr,
)
}
if albumErr == nil && len(albumTracks) == 0 && storeErr != nil {
return nil, storeErr
} }
if storeErr != nil { if storeErr != nil {
if albumErr != nil {
return nil, albumErr
}
return nil, storeErr return nil, storeErr
} }
return nil, fmt.Errorf("no tracks found for query: %s", query) return nil, fmt.Errorf("no tracks found for query: %s", query)
@@ -2125,7 +2381,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err) GoLog("[%s] Failed to get track by request Qobuz ID %d: %v\n", logPrefix, trackID, err)
track = nil track = nil
} else if track != nil { } else if track != nil {
if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID") { if qobuzTrackMatchesRequest(req, track, logPrefix, "request Qobuz ID", false) {
GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) GoLog("[%s] Successfully found track via request Qobuz ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
} else { } else {
track = nil track = nil
@@ -2142,7 +2398,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if err != nil { if err != nil {
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err) GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
track = nil track = nil
} else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID") { } else if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "cached Qobuz ID", false) {
track = nil track = nil
} }
} }
@@ -2162,7 +2418,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err) GoLog("[%s] Failed to get track by SongLink ID %d: %v\n", logPrefix, trackID, err)
track = nil track = nil
} else if track != nil { } else if track != nil {
if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID") { if qobuzTrackMatchesRequest(req, track, logPrefix, "SongLink Qobuz ID", true) {
GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name)
if req.ISRC != "" { if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID) GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -2179,7 +2435,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.ISRC != "" { if track == nil && req.ISRC != "" {
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC) GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec) track, err = qobuzSearchTrackByISRCWithDurationFunc(downloader, req.ISRC, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search") { if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "ISRC search", false) {
track = nil track = nil
} }
} }
@@ -2188,7 +2444,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil { if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName) GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec) track, err = qobuzSearchTrackByMetadataWithDurationFunc(downloader, req.TrackName, req.ArtistName, expectedDurationSec)
if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search") { if track != nil && !qobuzTrackMatchesRequest(req, track, logPrefix, "metadata search", false) {
track = nil track = nil
} }
} }
@@ -2253,7 +2509,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
qobuzQuality = "6" qobuzQuality = "6"
case "HI_RES": case "HI_RES":
qobuzQuality = "7" qobuzQuality = "7"
case "HI_RES_LOSSLESS": case "HI_RES_LOSSLESS", "", "DEFAULT":
qobuzQuality = "27" qobuzQuality = "27"
} }
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality) GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
@@ -2330,9 +2586,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
metadata := Metadata{ metadata := Metadata{
Title: track.Title, Title: track.Title,
Artist: track.Performer.Name, Artist: req.ArtistName,
Album: albumName, Album: albumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: releaseDate, Date: releaseDate,
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
@@ -2405,6 +2662,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
req.DiscNumber, req.DiscNumber,
) )
// Prefer the cover URL the frontend sent (user-selected album) over the
// track's default album cover returned by the Qobuz track/get API, which
// may belong to a different album when the same track appears on multiple
// releases.
resultCoverURL := strings.TrimSpace(req.CoverURL)
if resultCoverURL == "" {
resultCoverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
}
return QobuzDownloadResult{ return QobuzDownloadResult{
FilePath: outputPath, FilePath: outputPath,
BitDepth: actualBitDepth, BitDepth: actualBitDepth,
@@ -2416,7 +2682,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: resultTrackNumber, TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber, DiscNumber: resultDiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)), CoverURL: resultCoverURL,
LyricsLRC: lyricsLRC, LyricsLRC: lyricsLRC,
}, nil }, nil
} }
+94
View File
@@ -5,6 +5,21 @@ import (
"testing" "testing"
) )
func buildTestQobuzAlbum(id, title, artist string, tracks ...QobuzTrack) *qobuzAlbumDetails {
album := &qobuzAlbumDetails{
ID: id,
Title: title,
ReleaseDateOriginal: "2013-05-20",
TracksCount: len(tracks),
ProductType: "album",
ReleaseType: "album",
}
album.Artist = qobuzArtistRef{ID: 1, Name: artist}
album.Artists = []qobuzArtistRef{{ID: 1, Name: artist}}
album.Tracks.Items = tracks
return album
}
func TestParseQobuzURL(t *testing.T) { func TestParseQobuzURL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -276,6 +291,68 @@ func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
return track return track
} }
func TestSelectQobuzTracksFromAlbumSearchResultsPrefersMatchingTrack(t *testing.T) {
summaries := []qobuzAlbumDetails{
{ID: "album-a"},
{ID: "album-b"},
}
match := *testQobuzTrack(1, "Get Lucky", "Daft Punk", 369)
other := *testQobuzTrack(2, "Fragments of Time", "Daft Punk", 280)
fallback := *testQobuzTrack(3, "Da Funk", "Daft Punk", 330)
albums := map[string]*qobuzAlbumDetails{
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", match, other),
"album-b": buildTestQobuzAlbum("album-b", "Homework", "Daft Punk", fallback),
}
tracks, err := selectQobuzTracksFromAlbumSearchResults(
"daft punk get lucky",
3,
summaries,
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tracks) == 0 {
t.Fatal("expected tracks, got none")
}
if tracks[0].ID != 1 {
t.Fatalf("expected Get Lucky to rank first, got track id %d", tracks[0].ID)
}
}
func TestSelectQobuzTracksFromAlbumSearchResultsDedupesTracks(t *testing.T) {
summaries := []qobuzAlbumDetails{
{ID: "album-a"},
{ID: "album-b"},
}
shared := *testQobuzTrack(42, "Get Lucky", "Daft Punk", 369)
albums := map[string]*qobuzAlbumDetails{
"album-a": buildTestQobuzAlbum("album-a", "Random Access Memories", "Daft Punk", shared),
"album-b": buildTestQobuzAlbum("album-b", "Random Access Memories Deluxe", "Daft Punk", shared),
}
tracks, err := selectQobuzTracksFromAlbumSearchResults(
"daft punk get lucky",
5,
summaries,
func(id string) (*qobuzAlbumDetails, error) { return albums[id], nil },
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(tracks) != 1 {
t.Fatalf("expected 1 deduped track, got %d", len(tracks))
}
if tracks[0].ID != 42 {
t.Fatalf("unexpected deduped track id: %d", tracks[0].ID)
}
}
func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) { func TestResolveQobuzTrackForRequestRejectsSongLinkMismatch(t *testing.T) {
origGetTrackByID := qobuzGetTrackByIDFunc origGetTrackByID := qobuzGetTrackByIDFunc
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
@@ -436,3 +513,20 @@ func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testin
t.Fatalf("unexpected resolved track: %+v", track) t.Fatalf("unexpected resolved track: %+v", track)
} }
} }
func TestQobuzTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
}
track := &QobuzTrack{
Title: "Different Title",
Duration: 0,
}
track.Performer.Name = "Different Artist"
if !qobuzTrackMatchesRequest(req, track, "Qobuz", "SongLink Qobuz ID", true) {
t.Fatal("expected SongLink Qobuz source to bypass artist/title verification")
}
}
-80
View File
@@ -1,80 +0,0 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const DefaultSpotFetchAPIBaseURL = "https://sp.afkarxyz.qzz.io/api"
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
// This is used as a fallback when direct Spotify API access is blocked/limited.
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL)
if err != nil {
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
}
base := strings.TrimSpace(apiBaseURL)
if base == "" {
base = DefaultSpotFetchAPIBaseURL
}
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
}
switch parsed.Type {
case "track":
var trackResp TrackResponse
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
return nil, fmt.Errorf("failed to decode track response: %w", err)
}
return trackResp, nil
case "album":
var albumResp AlbumResponsePayload
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
return nil, fmt.Errorf("failed to decode album response: %w", err)
}
return &albumResp, nil
case "playlist":
var playlistResp PlaylistResponsePayload
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
}
return playlistResp, nil
case "artist":
var artistResp ArtistResponsePayload
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
return nil, fmt.Errorf("failed to decode artist response: %w", err)
}
return &artistResp, nil
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
}
}
+8 -1
View File
@@ -829,6 +829,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, a
resolved := resolvedTrackInfo{ resolved := resolvedTrackInfo{
Title: strings.TrimSpace(track.Title), Title: strings.TrimSpace(track.Title),
ArtistName: tidalTrackArtistsDisplay(track), ArtistName: tidalTrackArtistsDisplay(track),
ISRC: strings.TrimSpace(track.ISRC),
Duration: track.Duration, Duration: track.Duration,
} }
if trackMatchesRequest(req, resolved, "Tidal search") { if trackMatchesRequest(req, resolved, "Tidal search") {
@@ -2035,6 +2036,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
var trackID int64 var trackID int64
var gotTidalID bool var gotTidalID bool
var resolvedViaSongLink bool
if req.TidalID != "" { if req.TidalID != "" {
GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID) GoLog("[%s] Using Tidal ID from request payload: %s\n", logPrefix, req.TidalID)
@@ -2094,6 +2096,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
trackID = parsedTrackID trackID = parsedTrackID
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID) GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true gotTidalID = true
resolvedViaSongLink = true
return return
} }
} }
@@ -2103,6 +2106,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
if idErr == nil && trackID > 0 { if idErr == nil && trackID > 0 {
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID) GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
gotTidalID = true gotTidalID = true
resolvedViaSongLink = true
} }
} }
} }
@@ -2159,7 +2163,9 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
resolved := resolvedTrackInfo{ resolved := resolvedTrackInfo{
Title: actualTrack.Title, Title: actualTrack.Title,
ArtistName: providerArtist, ArtistName: providerArtist,
ISRC: strings.TrimSpace(actualTrack.ISRC),
Duration: actualTrack.Duration, Duration: actualTrack.Duration,
SkipNameVerification: resolvedViaSongLink,
} }
if !trackMatchesRequest(req, resolved, logPrefix) { if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it. // Invalidate the cached ID so future requests don't reuse it.
@@ -2206,7 +2212,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
} }
quality := req.Quality quality := req.Quality
if quality == "" { if quality == "" || quality == "DEFAULT" {
quality = "LOSSLESS" quality = "LOSSLESS"
} }
@@ -2352,6 +2358,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
AlbumArtist: req.AlbumArtist, AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: releaseDate, Date: releaseDate,
TrackNumber: actualTrackNumber, TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks, TotalTracks: req.TotalTracks,
+24 -1
View File
@@ -7,6 +7,21 @@ import (
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
) )
func writeNormalizedArtistRune(b *strings.Builder, r rune) {
switch r {
case 'đ':
b.WriteString("dj")
case 'ß':
b.WriteString("ss")
case 'æ':
b.WriteString("ae")
case 'œ':
b.WriteString("oe")
default:
b.WriteRune(r)
}
}
// normalizeLooseTitle collapses separators/punctuation so titles like // normalizeLooseTitle collapses separators/punctuation so titles like
// "Doctor / Cops" and "Doctor _ Cops" can still match. // "Doctor / Cops" and "Doctor _ Cops" can still match.
func normalizeLooseTitle(title string) string { func normalizeLooseTitle(title string) string {
@@ -51,7 +66,7 @@ func normalizeLooseArtistName(name string) string {
case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r): case unicode.Is(unicode.Mn, r), unicode.Is(unicode.Mc, r), unicode.Is(unicode.Me, r):
continue continue
case unicode.IsLetter(r), unicode.IsNumber(r): case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r) writeNormalizedArtistRune(&b, r)
case unicode.IsSpace(r): case unicode.IsSpace(r):
b.WriteByte(' ') b.WriteByte(' ')
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
@@ -103,12 +118,19 @@ func normalizeSymbolOnlyTitle(title string) string {
type resolvedTrackInfo struct { type resolvedTrackInfo struct {
Title string Title string
ArtistName string ArtistName string
ISRC string
Duration int Duration int
SkipNameVerification bool
} }
// trackMatchesRequest checks whether a resolved track from a provider matches // trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match. // the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool { func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
exactISRCMatch := req.ISRC != "" &&
resolved.ISRC != "" &&
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
if !exactISRCMatch && !resolved.SkipNameVerification {
if req.ArtistName != "" && resolved.ArtistName != "" && if req.ArtistName != "" && resolved.ArtistName != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) { !artistsMatch(req.ArtistName, resolved.ArtistName) {
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n", GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
@@ -122,6 +144,7 @@ func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPre
logPrefix, req.TrackName, resolved.Title) logPrefix, req.TrackName, resolved.Title)
return false return false
} }
}
expectedDurationSec := req.DurationMS / 1000 expectedDurationSec := req.DurationMS / 1000
if expectedDurationSec > 0 && resolved.Duration > 0 { if expectedDurationSec > 0 && resolved.Duration > 0 {
+34
View File
@@ -21,6 +21,40 @@ func TestNormalizeLooseTitle_EmojiAndSymbols(t *testing.T) {
} }
} }
func TestTrackMatchesRequest_SongLinkBypassesArtistAndTitle(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
}
resolved := resolvedTrackInfo{
Title: "Completely Different Title",
ArtistName: "Totally Different Artist",
SkipNameVerification: true,
}
if !trackMatchesRequest(req, resolved, "test") {
t.Fatal("expected SongLink-resolved track to bypass artist/title verification")
}
}
func TestTrackMatchesRequest_SongLinkStillChecksDuration(t *testing.T) {
req := DownloadRequest{
TrackName: "Ringišpil",
ArtistName: "Djordje Balasevic",
DurationMS: 180000,
}
resolved := resolvedTrackInfo{
Title: "Completely Different Title",
ArtistName: "Totally Different Artist",
Duration: 240,
SkipNameVerification: true,
}
if trackMatchesRequest(req, resolved, "test") {
t.Fatal("expected SongLink-resolved track with large duration mismatch to be rejected")
}
}
func TestTitlesMatch_SeparatorVariants(t *testing.T) { func TestTitlesMatch_SeparatorVariants(t *testing.T) {
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") { if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
t.Fatal("expected tidal titlesMatch to accept / vs _ variant") t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
-14
View File
@@ -153,13 +153,6 @@ import Gobackend // Import Go framework
var error: NSError? var error: NSError?
switch call.method { switch call.method {
case "parseSpotifyUrl":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendParseSpotifyURL(url, &error)
if let error = error { throw error }
return response
case "checkAvailability": case "checkAvailability":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String let spotifyId = args["spotify_id"] as! String
@@ -469,13 +462,6 @@ import Gobackend // Import Go framework
if let error = error { throw error } if let error = error { throw error }
return response return response
case "getSpotifyMetadataWithFallback":
let args = call.arguments as! [String: Any]
let url = args["url"] as! String
let response = GobackendGetSpotifyMetadataWithDeezerFallback(url, &error)
if let error = error { throw error }
return response
case "checkAvailabilityFromDeezerID": case "checkAvailabilityFromDeezerID":
let args = call.arguments as! [String: Any] let args = call.arguments as! [String: Any]
let deezerTrackId = args["deezer_track_id"] as! String let deezerTrackId = args["deezer_track_id"] as! String
+2 -2
View File
@@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart';
/// 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 = '4.1.1'; static const String version = '4.1.3';
static const String buildNumber = '118'; static const String buildNumber = '120';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';
/// Shows "Internal" in debug builds, actual version in release. /// Shows "Internal" in debug builds, actual version in release.
+407 -7
View File
@@ -151,7 +151,7 @@ abstract class AppLocalizations {
/// Bottom navigation - Extension store tab /// Bottom navigation - Extension store tab
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Store'** /// **'Repo'**
String get navStore; String get navStore;
/// Home screen title /// Home screen title
@@ -163,7 +163,7 @@ abstract class AppLocalizations {
/// Subtitle shown below search box /// Subtitle shown below search box
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Paste a Spotify link or search by name'** /// **'Paste a supported URL or search by name'**
String get homeSubtitle; String get homeSubtitle;
/// Info text about supported URL types /// Info text about supported URL types
@@ -400,6 +400,42 @@ abstract class AppLocalizations {
/// **'Download highest resolution cover art'** /// **'Download highest resolution cover art'**
String get optionsMaxQualityCoverSubtitle; String get optionsMaxQualityCoverSubtitle;
/// Setting title for how artist metadata is written into files
///
/// In en, this message translates to:
/// **'Artist Tag Mode'**
String get optionsArtistTagMode;
/// Bottom-sheet description for artist tag mode setting
///
/// In en, this message translates to:
/// **'Choose how multiple artists are written into embedded tags.'**
String get optionsArtistTagModeDescription;
/// Artist tag mode option that joins multiple artists into one value
///
/// In en, this message translates to:
/// **'Single joined value'**
String get optionsArtistTagModeJoined;
/// Subtitle for joined artist tag mode
///
/// In en, this message translates to:
/// **'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.'**
String get optionsArtistTagModeJoinedSubtitle;
/// Artist tag mode option that writes repeated ARTIST tags for Vorbis formats
///
/// In en, this message translates to:
/// **'Split tags for FLAC/Opus'**
String get optionsArtistTagModeSplitVorbis;
/// Subtitle for split Vorbis artist tag mode
///
/// In en, this message translates to:
/// **'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.'**
String get optionsArtistTagModeSplitVorbisSubtitle;
/// Number of parallel downloads /// Number of parallel downloads
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -427,13 +463,13 @@ abstract class AppLocalizations {
/// Show/hide store tab /// Show/hide store tab
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Extension Store'** /// **'Extension Repo'**
String get optionsExtensionStore; String get optionsExtensionStore;
/// Subtitle for extension store toggle /// Subtitle for extension store toggle
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Show Store tab in navigation'** /// **'Show Repo tab in navigation'**
String get optionsExtensionStoreSubtitle; String get optionsExtensionStoreSubtitle;
/// Auto update check toggle /// Auto update check toggle
@@ -565,7 +601,7 @@ abstract class AppLocalizations {
/// Store screen title /// Store screen title
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Extension Store'** /// **'Extension Repo'**
String get storeTitle; String get storeTitle;
/// Store search placeholder /// Store search placeholder
@@ -2365,7 +2401,7 @@ abstract class AppLocalizations {
/// Error heading when the store cannot be loaded /// Error heading when the store cannot be loaded
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Failed to load store'** /// **'Failed to load repository'**
String get storeLoadError; String get storeLoadError;
/// Message when store has no extensions /// Message when store has no extensions
@@ -3334,6 +3370,12 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{track} other{tracks}}'** /// **'{count, plural, =1{track} other{tracks}}'**
String libraryTracksUnit(int count); String libraryTracksUnit(int count);
/// Unit label for files count during library scanning
///
/// In en, this message translates to:
/// **'{count, plural, =1{file} other{files}}'**
String libraryFilesUnit(int count);
/// Last scan time display /// Last scan time display
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3352,6 +3394,12 @@ abstract class AppLocalizations {
/// **'Scanning...'** /// **'Scanning...'**
String get libraryScanning; String get libraryScanning;
/// Status shown after file scanning finishes but library persistence is still running
///
/// In en, this message translates to:
/// **'Finalizing library...'**
String get libraryScanFinalizing;
/// Scan progress display /// Scan progress display
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3478,6 +3526,42 @@ abstract class AppLocalizations {
/// **'Format'** /// **'Format'**
String get libraryFilterFormat; String get libraryFilterFormat;
/// Filter section - metadata completeness
///
/// In en, this message translates to:
/// **'Metadata'**
String get libraryFilterMetadata;
/// Filter option - items with complete metadata
///
/// In en, this message translates to:
/// **'Complete metadata'**
String get libraryFilterMetadataComplete;
/// Filter option - items missing any tracked metadata field
///
/// In en, this message translates to:
/// **'Missing any metadata'**
String get libraryFilterMetadataMissingAny;
/// Filter option - items missing release year/date
///
/// In en, this message translates to:
/// **'Missing year'**
String get libraryFilterMetadataMissingYear;
/// Filter option - items missing genre
///
/// In en, this message translates to:
/// **'Missing genre'**
String get libraryFilterMetadataMissingGenre;
/// Filter option - items missing album artist
///
/// In en, this message translates to:
/// **'Missing album artist'**
String get libraryFilterMetadataMissingAlbumArtist;
/// Filter section - sort order /// Filter section - sort order
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3496,6 +3580,30 @@ abstract class AppLocalizations {
/// **'Oldest'** /// **'Oldest'**
String get libraryFilterSortOldest; String get libraryFilterSortOldest;
/// Sort option - album ascending
///
/// In en, this message translates to:
/// **'Album (A-Z)'**
String get libraryFilterSortAlbumAsc;
/// Sort option - album descending
///
/// In en, this message translates to:
/// **'Album (Z-A)'**
String get libraryFilterSortAlbumDesc;
/// Sort option - genre ascending
///
/// In en, this message translates to:
/// **'Genre (A-Z)'**
String get libraryFilterSortGenreAsc;
/// Sort option - genre descending
///
/// In en, this message translates to:
/// **'Genre (Z-A)'**
String get libraryFilterSortGenreDesc;
/// Relative time - less than a minute ago /// Relative time - less than a minute ago
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -3613,7 +3721,7 @@ abstract class AppLocalizations {
/// Tutorial extensions tip 1 /// Tutorial extensions tip 1
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Browse the Store tab to discover useful extensions'** /// **'Browse the Repo tab to discover useful extensions'**
String get tutorialExtensionsTip1; String get tutorialExtensionsTip1;
/// Tutorial extensions tip 2 /// Tutorial extensions tip 2
@@ -5300,6 +5408,298 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Samples'** /// **'Samples'**
String get audioAnalysisSamples; String get audioAnalysisSamples;
/// Extensions page - subtitle for built-in search provider option
///
/// In en, this message translates to:
/// **'Search with {providerName}'**
String extensionsSearchWith(String providerName);
/// Extensions page - label for home feed provider selector
///
/// In en, this message translates to:
/// **'Home Feed Provider'**
String get extensionsHomeFeedProvider;
/// Extensions page - description for home feed provider picker
///
/// In en, this message translates to:
/// **'Choose which extension provides the home feed on the main screen'**
String get extensionsHomeFeedDescription;
/// Extensions page - home feed provider option: auto
///
/// In en, this message translates to:
/// **'Auto'**
String get extensionsHomeFeedAuto;
/// Extensions page - subtitle for auto home feed option
///
/// In en, this message translates to:
/// **'Automatically select the best available'**
String get extensionsHomeFeedAutoSubtitle;
/// Extensions page - subtitle for a specific extension home feed option
///
/// In en, this message translates to:
/// **'Use {extensionName} home feed'**
String extensionsHomeFeedUse(String extensionName);
/// Extensions page - shown when no installed extension has home feed
///
/// In en, this message translates to:
/// **'No extensions with home feed'**
String get extensionsNoHomeFeedExtensions;
/// Sort option - alphabetical ascending
///
/// In en, this message translates to:
/// **'A-Z'**
String get sortAlphaAsc;
/// Sort option - alphabetical descending
///
/// In en, this message translates to:
/// **'Z-A'**
String get sortAlphaDesc;
/// Dialog title when confirming cancellation of an active download
///
/// In en, this message translates to:
/// **'Cancel download?'**
String get cancelDownloadTitle;
/// Dialog body when confirming cancellation of an active download
///
/// In en, this message translates to:
/// **'This will cancel the active download for \"{trackName}\".'**
String cancelDownloadContent(String trackName);
/// Dialog button - keep the active download (do not cancel)
///
/// In en, this message translates to:
/// **'Keep'**
String get cancelDownloadKeep;
/// Snackbar error when FFmpeg fails to write metadata
///
/// In en, this message translates to:
/// **'Failed to save metadata via FFmpeg'**
String get metadataSaveFailedFfmpeg;
/// Snackbar error when writing metadata file back to storage fails
///
/// In en, this message translates to:
/// **'Failed to write metadata back to storage'**
String get metadataSaveFailedStorage;
/// Snackbar shown when folder picker fails to open
///
/// In en, this message translates to:
/// **'Failed to open folder picker: {error}'**
String snackbarFolderPickerFailed(String error);
/// Error state shown when album fails to load
///
/// In en, this message translates to:
/// **'Failed to load album'**
String get errorLoadAlbum;
/// Error state shown when playlist fails to load
///
/// In en, this message translates to:
/// **'Failed to load playlist'**
String get errorLoadPlaylist;
/// Error state shown when artist fails to load
///
/// In en, this message translates to:
/// **'Failed to load artist'**
String get errorLoadArtist;
/// Android notification channel name for download progress
///
/// In en, this message translates to:
/// **'Download Progress'**
String get notifChannelDownloadName;
/// Android notification channel description for download progress
///
/// In en, this message translates to:
/// **'Shows download progress for tracks'**
String get notifChannelDownloadDesc;
/// Android notification channel name for library scan
///
/// In en, this message translates to:
/// **'Library Scan'**
String get notifChannelLibraryScanName;
/// Android notification channel description for library scan
///
/// In en, this message translates to:
/// **'Shows local library scan progress'**
String get notifChannelLibraryScanDesc;
/// Notification title while downloading a track
///
/// In en, this message translates to:
/// **'Downloading {trackName}'**
String notifDownloadingTrack(String trackName);
/// Notification title while finalizing (embedding metadata) a track
///
/// In en, this message translates to:
/// **'Finalizing {trackName}'**
String notifFinalizingTrack(String trackName);
/// Notification body while embedding metadata into a downloaded track
///
/// In en, this message translates to:
/// **'Embedding metadata...'**
String get notifEmbeddingMetadata;
/// Notification title when track is already in library, with count
///
/// In en, this message translates to:
/// **'Already in Library ({completed}/{total})'**
String notifAlreadyInLibraryCount(int completed, int total);
/// Notification title when track is already in library
///
/// In en, this message translates to:
/// **'Already in Library'**
String get notifAlreadyInLibrary;
/// Notification title when download is complete, with count
///
/// In en, this message translates to:
/// **'Download Complete ({completed}/{total})'**
String notifDownloadCompleteCount(int completed, int total);
/// Notification title when a single download is complete
///
/// In en, this message translates to:
/// **'Download Complete'**
String get notifDownloadComplete;
/// Notification title when queue finishes with some failures
///
/// In en, this message translates to:
/// **'Downloads Finished ({completed} done, {failed} failed)'**
String notifDownloadsFinished(int completed, int failed);
/// Notification title when all downloads finish successfully
///
/// In en, this message translates to:
/// **'All Downloads Complete'**
String get notifAllDownloadsComplete;
/// Notification body for queue complete - how many tracks were downloaded
///
/// In en, this message translates to:
/// **'{count} tracks downloaded successfully'**
String notifTracksDownloadedSuccess(int count);
/// Notification title while scanning local library
///
/// In en, this message translates to:
/// **'Scanning local library'**
String get notifScanningLibrary;
/// Notification body for library scan progress when total is known
///
/// In en, this message translates to:
/// **'{scanned}/{total} files • {percentage}%'**
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
);
/// Notification body for library scan progress when total is unknown
///
/// In en, this message translates to:
/// **'{scanned} files scanned • {percentage}%'**
String notifLibraryScanProgressNoTotal(int scanned, int percentage);
/// Notification title when library scan finishes
///
/// In en, this message translates to:
/// **'Library scan complete'**
String get notifLibraryScanComplete;
/// Notification body for library scan complete - number of indexed tracks
///
/// In en, this message translates to:
/// **'{count} tracks indexed'**
String notifLibraryScanCompleteBody(int count);
/// Library scan complete suffix - excluded track count
///
/// In en, this message translates to:
/// **'{count} excluded'**
String notifLibraryScanExcluded(int count);
/// Library scan complete suffix - error count
///
/// In en, this message translates to:
/// **'{count} errors'**
String notifLibraryScanErrors(int count);
/// Notification title when library scan fails
///
/// In en, this message translates to:
/// **'Library scan failed'**
String get notifLibraryScanFailed;
/// Notification title when library scan is cancelled by the user
///
/// In en, this message translates to:
/// **'Library scan cancelled'**
String get notifLibraryScanCancelled;
/// Notification body when library scan is cancelled
///
/// In en, this message translates to:
/// **'Scan stopped before completion.'**
String get notifLibraryScanStopped;
/// Notification title while downloading an app update
///
/// In en, this message translates to:
/// **'Downloading SpotiFLAC v{version}'**
String notifDownloadingUpdate(String version);
/// Notification body showing update download progress
///
/// In en, this message translates to:
/// **'{received} / {total} MB • {percentage}%'**
String notifUpdateProgress(String received, String total, int percentage);
/// Notification title when app update download is complete
///
/// In en, this message translates to:
/// **'Update Ready'**
String get notifUpdateReady;
/// Notification body when app update is ready to install
///
/// In en, this message translates to:
/// **'SpotiFLAC v{version} downloaded. Tap to install.'**
String notifUpdateReadyBody(String version);
/// Notification title when app update download fails
///
/// In en, this message translates to:
/// **'Update Failed'**
String get notifUpdateFailed;
/// Notification body when app update download fails
///
/// In en, this message translates to:
/// **'Could not download update. Try again later.'**
String get notifUpdateFailedBody;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+254 -1
View File
@@ -158,6 +158,27 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Cover in höchster Auflösung herunterladen'; 'Cover in höchster Auflösung herunterladen';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Parallele Downloads'; String get optionsConcurrentDownloads => 'Parallele Downloads';
@@ -1281,7 +1302,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1851,6 +1872,17 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Zuletzt gescannt: $time'; return 'Zuletzt gescannt: $time';
@@ -1862,6 +1894,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryScanning => 'Scannen...'; String get libraryScanning => 'Scannen...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% von $total Dateien'; return '$progress% von $total Dateien';
@@ -1930,6 +1965,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sortieren'; String get libraryFilterSort => 'Sortieren';
@@ -1939,6 +1992,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Älteste'; String get libraryFilterSortOldest => 'Älteste';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Gerade eben'; String get timeJustNow => 'Gerade eben';
@@ -3124,4 +3189,192 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+260 -7
View File
@@ -21,13 +21,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get navSettings => 'Settings'; String get navSettings => 'Settings';
@override @override
String get navStore => 'Store'; String get navStore => 'Repo';
@override @override
String get homeTitle => 'Home'; String get homeTitle => 'Home';
@override @override
String get homeSubtitle => 'Paste a Spotify link or search by name'; String get homeSubtitle => 'Paste a supported URL or search by name';
@override @override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs'; String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -154,6 +154,27 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -170,10 +191,10 @@ class AppLocalizationsEn extends AppLocalizations {
'Parallel downloads may trigger rate limiting'; 'Parallel downloads may trigger rate limiting';
@override @override
String get optionsExtensionStore => 'Extension Store'; String get optionsExtensionStore => 'Extension Repo';
@override @override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation'; String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override @override
String get optionsCheckUpdates => 'Check for Updates'; String get optionsCheckUpdates => 'Check for Updates';
@@ -250,7 +271,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get extensionsUninstall => 'Uninstall'; String get extensionsUninstall => 'Uninstall';
@override @override
String get storeTitle => 'Extension Store'; String get storeTitle => 'Extension Repo';
@override @override
String get storeSearch => 'Search extensions...'; String get storeSearch => 'Search extensions...';
@@ -1261,7 +1282,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -1997,7 +2062,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3092,4 +3157,192 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+255 -2
View File
@@ -154,6 +154,27 @@ class AppLocalizationsEs extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsEs extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -1997,7 +2062,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3092,6 +3157,194 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).
+254 -1
View File
@@ -156,6 +156,27 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1263,7 +1284,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1825,6 +1846,17 @@ class AppLocalizationsFr extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1836,6 +1868,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1904,6 +1939,24 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1913,6 +1966,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -3093,4 +3158,192 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+254 -1
View File
@@ -154,6 +154,27 @@ class AppLocalizationsHi extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsHi extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -3091,4 +3156,192 @@ class AppLocalizationsHi extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+261 -7
View File
@@ -21,13 +21,14 @@ class AppLocalizationsId extends AppLocalizations {
String get navSettings => 'Pengaturan'; String get navSettings => 'Pengaturan';
@override @override
String get navStore => 'Toko'; String get navStore => 'Repo';
@override @override
String get homeTitle => 'Beranda'; String get homeTitle => 'Beranda';
@override @override
String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama'; String get homeSubtitle =>
'Tempel URL yang didukung atau cari berdasarkan nama';
@override @override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis'; String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -157,6 +158,27 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Unduh cover art resolusi tertinggi'; 'Unduh cover art resolusi tertinggi';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Unduhan Bersamaan'; String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
@@ -173,10 +195,10 @@ class AppLocalizationsId extends AppLocalizations {
'Unduhan paralel dapat memicu pembatasan rate'; 'Unduhan paralel dapat memicu pembatasan rate';
@override @override
String get optionsExtensionStore => 'Toko Ekstensi'; String get optionsExtensionStore => 'Repo Ekstensi';
@override @override
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Toko di navigasi'; String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
@override @override
String get optionsCheckUpdates => 'Periksa Pembaruan'; String get optionsCheckUpdates => 'Periksa Pembaruan';
@@ -252,7 +274,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsUninstall => 'Copot'; String get extensionsUninstall => 'Copot';
@override @override
String get storeTitle => 'Toko Ekstensi'; String get storeTitle => 'Repo Ekstensi';
@override @override
String get storeSearch => 'Cari ekstensi...'; String get storeSearch => 'Cari ekstensi...';
@@ -1267,7 +1289,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Gagal memuat repo';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1832,6 +1854,17 @@ class AppLocalizationsId extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1843,6 +1876,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1911,6 +1947,24 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1920,6 +1974,18 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -2006,7 +2072,7 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Buka tab Repo untuk menemukan ekstensi yang berguna';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3101,4 +3167,192 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+254 -1
View File
@@ -152,6 +152,27 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード'; String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => '同時ダウンロード'; String get optionsConcurrentDownloads => '同時ダウンロード';
@@ -1255,7 +1276,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1810,6 +1831,17 @@ class AppLocalizationsJa extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return '最終スキャン: $time'; return '最終スキャン: $time';
@@ -1821,6 +1853,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get libraryScanning => 'スキャン中...'; String get libraryScanning => 'スキャン中...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1889,6 +1924,24 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get libraryFilterFormat => '形式'; String get libraryFilterFormat => '形式';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1898,6 +1951,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -3078,4 +3143,192 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+254 -1
View File
@@ -148,6 +148,27 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드'; String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => '동시 다운로드'; String get optionsConcurrentDownloads => '동시 다운로드';
@@ -1241,7 +1262,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1803,6 +1824,17 @@ class AppLocalizationsKo extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1814,6 +1846,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1882,6 +1917,24 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1891,6 +1944,18 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -3071,4 +3136,192 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+254 -1
View File
@@ -154,6 +154,27 @@ class AppLocalizationsNl extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsNl extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -3091,4 +3156,192 @@ class AppLocalizationsNl extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+255 -2
View File
@@ -154,6 +154,27 @@ class AppLocalizationsPt extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsPt extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -1997,7 +2062,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3092,6 +3157,194 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).
+254 -1
View File
@@ -159,6 +159,27 @@ class AppLocalizationsRu extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Скачивать обложку в макс. разрешении'; 'Скачивать обложку в макс. разрешении';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Одновременные загрузки'; String get optionsConcurrentDownloads => 'Одновременные загрузки';
@@ -1282,7 +1303,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1861,6 +1882,17 @@ class AppLocalizationsRu extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Последнее сканирование: $time'; return 'Последнее сканирование: $time';
@@ -1872,6 +1904,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get libraryScanning => 'Сканирование...'; String get libraryScanning => 'Сканирование...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% из $total файлов'; return '$progress% из $total файлов';
@@ -1948,6 +1983,24 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Формат'; String get libraryFilterFormat => 'Формат';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Сортировка'; String get libraryFilterSort => 'Сортировка';
@@ -1957,6 +2010,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Старые'; String get libraryFilterSortOldest => 'Старые';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Только что'; String get timeJustNow => 'Только что';
@@ -3151,4 +3216,192 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+254 -1
View File
@@ -157,6 +157,27 @@ class AppLocalizationsTr extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'En yüksek kalitedeki albüm kapaklarını indir'; 'En yüksek kalitedeki albüm kapaklarını indir';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler'; String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler';
@@ -1267,7 +1288,7 @@ class AppLocalizationsTr extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1829,6 +1850,17 @@ class AppLocalizationsTr extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1840,6 +1872,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1908,6 +1943,24 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1917,6 +1970,18 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -3097,4 +3162,192 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
+255 -2
View File
@@ -154,6 +154,27 @@ class AppLocalizationsZh extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle => String get optionsMaxQualityCoverSubtitle =>
'Download highest resolution cover art'; 'Download highest resolution cover art';
@override
String get optionsArtistTagMode => 'Artist Tag Mode';
@override
String get optionsArtistTagModeDescription =>
'Choose how multiple artists are written into embedded tags.';
@override
String get optionsArtistTagModeJoined => 'Single joined value';
@override
String get optionsArtistTagModeJoinedSubtitle =>
'Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.';
@override
String get optionsArtistTagModeSplitVorbis => 'Split tags for FLAC/Opus';
@override
String get optionsArtistTagModeSplitVorbisSubtitle =>
'Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.';
@override @override
String get optionsConcurrentDownloads => 'Concurrent Downloads'; String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL'; String get storeNewRepoUrlLabel => 'New Repository URL';
@override @override
String get storeLoadError => 'Failed to load store'; String get storeLoadError => 'Failed to load repository';
@override @override
String get storeEmptyNoExtensions => 'No extensions available'; String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsZh extends AppLocalizations {
return '$_temp0'; return '$_temp0';
} }
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override @override
String libraryLastScanned(String time) { String libraryLastScanned(String time) {
return 'Last scanned: $time'; return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get libraryScanning => 'Scanning...'; String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override @override
String libraryScanProgress(String progress, int total) { String libraryScanProgress(String progress, int total) {
return '$progress% of $total files'; return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get libraryFilterFormat => 'Format'; String get libraryFilterFormat => 'Format';
@override
String get libraryFilterMetadata => 'Metadata';
@override
String get libraryFilterMetadataComplete => 'Complete metadata';
@override
String get libraryFilterMetadataMissingAny => 'Missing any metadata';
@override
String get libraryFilterMetadataMissingYear => 'Missing year';
@override
String get libraryFilterMetadataMissingGenre => 'Missing genre';
@override
String get libraryFilterMetadataMissingAlbumArtist => 'Missing album artist';
@override @override
String get libraryFilterSort => 'Sort'; String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get libraryFilterSortOldest => 'Oldest'; String get libraryFilterSortOldest => 'Oldest';
@override
String get libraryFilterSortAlbumAsc => 'Album (A-Z)';
@override
String get libraryFilterSortAlbumDesc => 'Album (Z-A)';
@override
String get libraryFilterSortGenreAsc => 'Genre (A-Z)';
@override
String get libraryFilterSortGenreDesc => 'Genre (Z-A)';
@override @override
String get timeJustNow => 'Just now'; String get timeJustNow => 'Just now';
@@ -1997,7 +2062,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get tutorialExtensionsTip1 => String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions'; 'Browse the Repo tab to discover useful extensions';
@override @override
String get tutorialExtensionsTip2 => String get tutorialExtensionsTip2 =>
@@ -3092,6 +3157,194 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get audioAnalysisSamples => 'Samples'; String get audioAnalysisSamples => 'Samples';
@override
String extensionsSearchWith(String providerName) {
return 'Search with $providerName';
}
@override
String get extensionsHomeFeedProvider => 'Home Feed Provider';
@override
String get extensionsHomeFeedDescription =>
'Choose which extension provides the home feed on the main screen';
@override
String get extensionsHomeFeedAuto => 'Auto';
@override
String get extensionsHomeFeedAutoSubtitle =>
'Automatically select the best available';
@override
String extensionsHomeFeedUse(String extensionName) {
return 'Use $extensionName home feed';
}
@override
String get extensionsNoHomeFeedExtensions => 'No extensions with home feed';
@override
String get sortAlphaAsc => 'A-Z';
@override
String get sortAlphaDesc => 'Z-A';
@override
String get cancelDownloadTitle => 'Cancel download?';
@override
String cancelDownloadContent(String trackName) {
return 'This will cancel the active download for \"$trackName\".';
}
@override
String get cancelDownloadKeep => 'Keep';
@override
String get metadataSaveFailedFfmpeg => 'Failed to save metadata via FFmpeg';
@override
String get metadataSaveFailedStorage =>
'Failed to write metadata back to storage';
@override
String snackbarFolderPickerFailed(String error) {
return 'Failed to open folder picker: $error';
}
@override
String get errorLoadAlbum => 'Failed to load album';
@override
String get errorLoadPlaylist => 'Failed to load playlist';
@override
String get errorLoadArtist => 'Failed to load artist';
@override
String get notifChannelDownloadName => 'Download Progress';
@override
String get notifChannelDownloadDesc => 'Shows download progress for tracks';
@override
String get notifChannelLibraryScanName => 'Library Scan';
@override
String get notifChannelLibraryScanDesc => 'Shows local library scan progress';
@override
String notifDownloadingTrack(String trackName) {
return 'Downloading $trackName';
}
@override
String notifFinalizingTrack(String trackName) {
return 'Finalizing $trackName';
}
@override
String get notifEmbeddingMetadata => 'Embedding metadata...';
@override
String notifAlreadyInLibraryCount(int completed, int total) {
return 'Already in Library ($completed/$total)';
}
@override
String get notifAlreadyInLibrary => 'Already in Library';
@override
String notifDownloadCompleteCount(int completed, int total) {
return 'Download Complete ($completed/$total)';
}
@override
String get notifDownloadComplete => 'Download Complete';
@override
String notifDownloadsFinished(int completed, int failed) {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
@override
String notifTracksDownloadedSuccess(int count) {
return '$count tracks downloaded successfully';
}
@override
String get notifScanningLibrary => 'Scanning local library';
@override
String notifLibraryScanProgressWithTotal(
int scanned,
int total,
int percentage,
) {
return '$scanned/$total files • $percentage%';
}
@override
String notifLibraryScanProgressNoTotal(int scanned, int percentage) {
return '$scanned files scanned • $percentage%';
}
@override
String get notifLibraryScanComplete => 'Library scan complete';
@override
String notifLibraryScanCompleteBody(int count) {
return '$count tracks indexed';
}
@override
String notifLibraryScanExcluded(int count) {
return '$count excluded';
}
@override
String notifLibraryScanErrors(int count) {
return '$count errors';
}
@override
String get notifLibraryScanFailed => 'Library scan failed';
@override
String get notifLibraryScanCancelled => 'Library scan cancelled';
@override
String get notifLibraryScanStopped => 'Scan stopped before completion.';
@override
String notifDownloadingUpdate(String version) {
return 'Downloading SpotiFLAC v$version';
}
@override
String notifUpdateProgress(String received, String total, int percentage) {
return '$received / $total MB • $percentage%';
}
@override
String get notifUpdateReady => 'Update Ready';
@override
String notifUpdateReadyBody(String version) {
return 'SpotiFLAC v$version downloaded. Tap to install.';
}
@override
String get notifUpdateFailed => 'Update Failed';
@override
String get notifUpdateFailedBody =>
'Could not download update. Try again later.';
} }
/// The translations for Chinese, as used in China (`zh_CN`). /// The translations for Chinese, as used in China (`zh_CN`).
+396 -7
View File
@@ -17,7 +17,7 @@
"@navSettings": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
"navStore": "Store", "navStore": "Repo",
"@navStore": { "@navStore": {
"description": "Bottom navigation - Extension store tab" "description": "Bottom navigation - Extension store tab"
}, },
@@ -25,7 +25,7 @@
"@homeTitle": { "@homeTitle": {
"description": "Home screen title" "description": "Home screen title"
}, },
"homeSubtitle": "Paste a Spotify link or search by name", "homeSubtitle": "Paste a supported URL or search by name",
"@homeSubtitle": { "@homeSubtitle": {
"description": "Subtitle shown below search box" "description": "Subtitle shown below search box"
}, },
@@ -190,6 +190,30 @@
"@optionsMaxQualityCoverSubtitle": { "@optionsMaxQualityCoverSubtitle": {
"description": "Subtitle for max quality cover" "description": "Subtitle for max quality cover"
}, },
"optionsArtistTagMode": "Artist Tag Mode",
"@optionsArtistTagMode": {
"description": "Setting title for how artist metadata is written into files"
},
"optionsArtistTagModeDescription": "Choose how multiple artists are written into embedded tags.",
"@optionsArtistTagModeDescription": {
"description": "Bottom-sheet description for artist tag mode setting"
},
"optionsArtistTagModeJoined": "Single joined value",
"@optionsArtistTagModeJoined": {
"description": "Artist tag mode option that joins multiple artists into one value"
},
"optionsArtistTagModeJoinedSubtitle": "Write one ARTIST value like \"Artist A, Artist B\" for maximum player compatibility.",
"@optionsArtistTagModeJoinedSubtitle": {
"description": "Subtitle for joined artist tag mode"
},
"optionsArtistTagModeSplitVorbis": "Split tags for FLAC/Opus",
"@optionsArtistTagModeSplitVorbis": {
"description": "Artist tag mode option that writes repeated ARTIST tags for Vorbis formats"
},
"optionsArtistTagModeSplitVorbisSubtitle": "Write one artist tag per artist for FLAC and Opus; MP3 and M4A stay joined.",
"@optionsArtistTagModeSplitVorbisSubtitle": {
"description": "Subtitle for split Vorbis artist tag mode"
},
"optionsConcurrentDownloads": "Concurrent Downloads", "optionsConcurrentDownloads": "Concurrent Downloads",
"@optionsConcurrentDownloads": { "@optionsConcurrentDownloads": {
"description": "Number of parallel downloads" "description": "Number of parallel downloads"
@@ -211,11 +235,11 @@
"@optionsConcurrentWarning": { "@optionsConcurrentWarning": {
"description": "Warning about rate limits" "description": "Warning about rate limits"
}, },
"optionsExtensionStore": "Extension Store", "optionsExtensionStore": "Extension Repo",
"@optionsExtensionStore": { "@optionsExtensionStore": {
"description": "Show/hide store tab" "description": "Show/hide store tab"
}, },
"optionsExtensionStoreSubtitle": "Show Store tab in navigation", "optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
"@optionsExtensionStoreSubtitle": { "@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle" "description": "Subtitle for extension store toggle"
}, },
@@ -318,7 +342,7 @@
"@extensionsUninstall": { "@extensionsUninstall": {
"description": "Uninstall extension button" "description": "Uninstall extension button"
}, },
"storeTitle": "Extension Store", "storeTitle": "Extension Repo",
"@storeTitle": { "@storeTitle": {
"description": "Store screen title" "description": "Store screen title"
}, },
@@ -1654,7 +1678,7 @@
"@storeNewRepoUrlLabel": { "@storeNewRepoUrlLabel": {
"description": "Label for the new repository URL field inside the dialog" "description": "Label for the new repository URL field inside the dialog"
}, },
"storeLoadError": "Failed to load store", "storeLoadError": "Failed to load repository",
"@storeLoadError": { "@storeLoadError": {
"description": "Error heading when the store cannot be loaded" "description": "Error heading when the store cannot be loaded"
}, },
@@ -2399,6 +2423,15 @@
} }
} }
}, },
"libraryFilesUnit": "{count, plural, =1{file} other{files}}",
"@libraryFilesUnit": {
"description": "Unit label for files count during library scanning",
"placeholders": {
"count": {
"type": "int"
}
}
},
"libraryLastScanned": "Last scanned: {time}", "libraryLastScanned": "Last scanned: {time}",
"@libraryLastScanned": { "@libraryLastScanned": {
"description": "Last scan time display", "description": "Last scan time display",
@@ -2416,6 +2449,10 @@
"@libraryScanning": { "@libraryScanning": {
"description": "Status during scan" "description": "Status during scan"
}, },
"libraryScanFinalizing": "Finalizing library...",
"@libraryScanFinalizing": {
"description": "Status shown after file scanning finishes but library persistence is still running"
},
"libraryScanProgress": "{progress}% of {total} files", "libraryScanProgress": "{progress}% of {total} files",
"@libraryScanProgress": { "@libraryScanProgress": {
"description": "Scan progress display", "description": "Scan progress display",
@@ -2513,6 +2550,30 @@
"@libraryFilterFormat": { "@libraryFilterFormat": {
"description": "Filter section - file format" "description": "Filter section - file format"
}, },
"libraryFilterMetadata": "Metadata",
"@libraryFilterMetadata": {
"description": "Filter section - metadata completeness"
},
"libraryFilterMetadataComplete": "Complete metadata",
"@libraryFilterMetadataComplete": {
"description": "Filter option - items with complete metadata"
},
"libraryFilterMetadataMissingAny": "Missing any metadata",
"@libraryFilterMetadataMissingAny": {
"description": "Filter option - items missing any tracked metadata field"
},
"libraryFilterMetadataMissingYear": "Missing year",
"@libraryFilterMetadataMissingYear": {
"description": "Filter option - items missing release year/date"
},
"libraryFilterMetadataMissingGenre": "Missing genre",
"@libraryFilterMetadataMissingGenre": {
"description": "Filter option - items missing genre"
},
"libraryFilterMetadataMissingAlbumArtist": "Missing album artist",
"@libraryFilterMetadataMissingAlbumArtist": {
"description": "Filter option - items missing album artist"
},
"libraryFilterSort": "Sort", "libraryFilterSort": "Sort",
"@libraryFilterSort": { "@libraryFilterSort": {
"description": "Filter section - sort order" "description": "Filter section - sort order"
@@ -2525,6 +2586,22 @@
"@libraryFilterSortOldest": { "@libraryFilterSortOldest": {
"description": "Sort option - oldest first" "description": "Sort option - oldest first"
}, },
"libraryFilterSortAlbumAsc": "Album (A-Z)",
"@libraryFilterSortAlbumAsc": {
"description": "Sort option - album ascending"
},
"libraryFilterSortAlbumDesc": "Album (Z-A)",
"@libraryFilterSortAlbumDesc": {
"description": "Sort option - album descending"
},
"libraryFilterSortGenreAsc": "Genre (A-Z)",
"@libraryFilterSortGenreAsc": {
"description": "Sort option - genre ascending"
},
"libraryFilterSortGenreDesc": "Genre (Z-A)",
"@libraryFilterSortGenreDesc": {
"description": "Sort option - genre descending"
},
"timeJustNow": "Just now", "timeJustNow": "Just now",
"@timeJustNow": { "@timeJustNow": {
"description": "Relative time - less than a minute ago" "description": "Relative time - less than a minute ago"
@@ -2611,7 +2688,7 @@
"@tutorialExtensionsDesc": { "@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description" "description": "Tutorial extensions page description"
}, },
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", "tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
"@tutorialExtensionsTip1": { "@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1" "description": "Tutorial extensions tip 1"
}, },
@@ -4063,5 +4140,317 @@
"audioAnalysisSamples": "Samples", "audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": { "@audioAnalysisSamples": {
"description": "Total samples metric label" "description": "Total samples metric label"
},
"extensionsSearchWith": "Search with {providerName}",
"@extensionsSearchWith": {
"description": "Extensions page - subtitle for built-in search provider option",
"placeholders": {
"providerName": {
"type": "String"
}
}
},
"extensionsHomeFeedProvider": "Home Feed Provider",
"@extensionsHomeFeedProvider": {
"description": "Extensions page - label for home feed provider selector"
},
"extensionsHomeFeedDescription": "Choose which extension provides the home feed on the main screen",
"@extensionsHomeFeedDescription": {
"description": "Extensions page - description for home feed provider picker"
},
"extensionsHomeFeedAuto": "Auto",
"@extensionsHomeFeedAuto": {
"description": "Extensions page - home feed provider option: auto"
},
"extensionsHomeFeedAutoSubtitle": "Automatically select the best available",
"@extensionsHomeFeedAutoSubtitle": {
"description": "Extensions page - subtitle for auto home feed option"
},
"extensionsHomeFeedUse": "Use {extensionName} home feed",
"@extensionsHomeFeedUse": {
"description": "Extensions page - subtitle for a specific extension home feed option",
"placeholders": {
"extensionName": {
"type": "String"
}
}
},
"extensionsNoHomeFeedExtensions": "No extensions with home feed",
"@extensionsNoHomeFeedExtensions": {
"description": "Extensions page - shown when no installed extension has home feed"
},
"sortAlphaAsc": "A-Z",
"@sortAlphaAsc": {
"description": "Sort option - alphabetical ascending"
},
"sortAlphaDesc": "Z-A",
"@sortAlphaDesc": {
"description": "Sort option - alphabetical descending"
},
"cancelDownloadTitle": "Cancel download?",
"@cancelDownloadTitle": {
"description": "Dialog title when confirming cancellation of an active download"
},
"cancelDownloadContent": "This will cancel the active download for \"{trackName}\".",
"@cancelDownloadContent": {
"description": "Dialog body when confirming cancellation of an active download",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"cancelDownloadKeep": "Keep",
"@cancelDownloadKeep": {
"description": "Dialog button - keep the active download (do not cancel)"
},
"metadataSaveFailedFfmpeg": "Failed to save metadata via FFmpeg",
"@metadataSaveFailedFfmpeg": {
"description": "Snackbar error when FFmpeg fails to write metadata"
},
"metadataSaveFailedStorage": "Failed to write metadata back to storage",
"@metadataSaveFailedStorage": {
"description": "Snackbar error when writing metadata file back to storage fails"
},
"snackbarFolderPickerFailed": "Failed to open folder picker: {error}",
"@snackbarFolderPickerFailed": {
"description": "Snackbar shown when folder picker fails to open",
"placeholders": {
"error": {
"type": "String"
}
}
},
"errorLoadAlbum": "Failed to load album",
"@errorLoadAlbum": {
"description": "Error state shown when album fails to load"
},
"errorLoadPlaylist": "Failed to load playlist",
"@errorLoadPlaylist": {
"description": "Error state shown when playlist fails to load"
},
"errorLoadArtist": "Failed to load artist",
"@errorLoadArtist": {
"description": "Error state shown when artist fails to load"
},
"notifChannelDownloadName": "Download Progress",
"@notifChannelDownloadName": {
"description": "Android notification channel name for download progress"
},
"notifChannelDownloadDesc": "Shows download progress for tracks",
"@notifChannelDownloadDesc": {
"description": "Android notification channel description for download progress"
},
"notifChannelLibraryScanName": "Library Scan",
"@notifChannelLibraryScanName": {
"description": "Android notification channel name for library scan"
},
"notifChannelLibraryScanDesc": "Shows local library scan progress",
"@notifChannelLibraryScanDesc": {
"description": "Android notification channel description for library scan"
},
"notifDownloadingTrack": "Downloading {trackName}",
"@notifDownloadingTrack": {
"description": "Notification title while downloading a track",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"notifFinalizingTrack": "Finalizing {trackName}",
"@notifFinalizingTrack": {
"description": "Notification title while finalizing (embedding metadata) a track",
"placeholders": {
"trackName": {
"type": "String"
}
}
},
"notifEmbeddingMetadata": "Embedding metadata...",
"@notifEmbeddingMetadata": {
"description": "Notification body while embedding metadata into a downloaded track"
},
"notifAlreadyInLibraryCount": "Already in Library ({completed}/{total})",
"@notifAlreadyInLibraryCount": {
"description": "Notification title when track is already in library, with count",
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"notifAlreadyInLibrary": "Already in Library",
"@notifAlreadyInLibrary": {
"description": "Notification title when track is already in library"
},
"notifDownloadCompleteCount": "Download Complete ({completed}/{total})",
"@notifDownloadCompleteCount": {
"description": "Notification title when download is complete, with count",
"placeholders": {
"completed": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"notifDownloadComplete": "Download Complete",
"@notifDownloadComplete": {
"description": "Notification title when a single download is complete"
},
"notifDownloadsFinished": "Downloads Finished ({completed} done, {failed} failed)",
"@notifDownloadsFinished": {
"description": "Notification title when queue finishes with some failures",
"placeholders": {
"completed": {
"type": "int"
},
"failed": {
"type": "int"
}
}
},
"notifAllDownloadsComplete": "All Downloads Complete",
"@notifAllDownloadsComplete": {
"description": "Notification title when all downloads finish successfully"
},
"notifTracksDownloadedSuccess": "{count} tracks downloaded successfully",
"@notifTracksDownloadedSuccess": {
"description": "Notification body for queue complete - how many tracks were downloaded",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifScanningLibrary": "Scanning local library",
"@notifScanningLibrary": {
"description": "Notification title while scanning local library"
},
"notifLibraryScanProgressWithTotal": "{scanned}/{total} files • {percentage}%",
"@notifLibraryScanProgressWithTotal": {
"description": "Notification body for library scan progress when total is known",
"placeholders": {
"scanned": {
"type": "int"
},
"total": {
"type": "int"
},
"percentage": {
"type": "int"
}
}
},
"notifLibraryScanProgressNoTotal": "{scanned} files scanned • {percentage}%",
"@notifLibraryScanProgressNoTotal": {
"description": "Notification body for library scan progress when total is unknown",
"placeholders": {
"scanned": {
"type": "int"
},
"percentage": {
"type": "int"
}
}
},
"notifLibraryScanComplete": "Library scan complete",
"@notifLibraryScanComplete": {
"description": "Notification title when library scan finishes"
},
"notifLibraryScanCompleteBody": "{count} tracks indexed",
"@notifLibraryScanCompleteBody": {
"description": "Notification body for library scan complete - number of indexed tracks",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanExcluded": "{count} excluded",
"@notifLibraryScanExcluded": {
"description": "Library scan complete suffix - excluded track count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanErrors": "{count} errors",
"@notifLibraryScanErrors": {
"description": "Library scan complete suffix - error count",
"placeholders": {
"count": {
"type": "int"
}
}
},
"notifLibraryScanFailed": "Library scan failed",
"@notifLibraryScanFailed": {
"description": "Notification title when library scan fails"
},
"notifLibraryScanCancelled": "Library scan cancelled",
"@notifLibraryScanCancelled": {
"description": "Notification title when library scan is cancelled by the user"
},
"notifLibraryScanStopped": "Scan stopped before completion.",
"@notifLibraryScanStopped": {
"description": "Notification body when library scan is cancelled"
},
"notifDownloadingUpdate": "Downloading SpotiFLAC v{version}",
"@notifDownloadingUpdate": {
"description": "Notification title while downloading an app update",
"placeholders": {
"version": {
"type": "String"
}
}
},
"notifUpdateProgress": "{received} / {total} MB • {percentage}%",
"@notifUpdateProgress": {
"description": "Notification body showing update download progress",
"placeholders": {
"received": {
"type": "String"
},
"total": {
"type": "String"
},
"percentage": {
"type": "int"
}
}
},
"notifUpdateReady": "Update Ready",
"@notifUpdateReady": {
"description": "Notification title when app update download is complete"
},
"notifUpdateReadyBody": "SpotiFLAC v{version} downloaded. Tap to install.",
"@notifUpdateReadyBody": {
"description": "Notification body when app update is ready to install",
"placeholders": {
"version": {
"type": "String"
}
}
},
"notifUpdateFailed": "Update Failed",
"@notifUpdateFailed": {
"description": "Notification title when app update download fails"
},
"notifUpdateFailedBody": "Could not download update. Try again later.",
"@notifUpdateFailedBody": {
"description": "Notification body when app update download fails"
} }
} }
+10 -6
View File
@@ -17,7 +17,7 @@
"@navSettings": { "@navSettings": {
"description": "Bottom navigation - Settings tab" "description": "Bottom navigation - Settings tab"
}, },
"navStore": "Toko", "navStore": "Repo",
"@navStore": { "@navStore": {
"description": "Bottom navigation - Extension store tab" "description": "Bottom navigation - Extension store tab"
}, },
@@ -25,7 +25,7 @@
"@homeTitle": { "@homeTitle": {
"description": "Home screen title" "description": "Home screen title"
}, },
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama", "homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
"@homeSubtitle": { "@homeSubtitle": {
"description": "Subtitle shown below search box" "description": "Subtitle shown below search box"
}, },
@@ -211,11 +211,11 @@
"@optionsConcurrentWarning": { "@optionsConcurrentWarning": {
"description": "Warning about rate limits" "description": "Warning about rate limits"
}, },
"optionsExtensionStore": "Toko Ekstensi", "optionsExtensionStore": "Repo Ekstensi",
"@optionsExtensionStore": { "@optionsExtensionStore": {
"description": "Show/hide store tab" "description": "Show/hide store tab"
}, },
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi", "optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
"@optionsExtensionStoreSubtitle": { "@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle" "description": "Subtitle for extension store toggle"
}, },
@@ -318,10 +318,14 @@
"@extensionsUninstall": { "@extensionsUninstall": {
"description": "Uninstall extension button" "description": "Uninstall extension button"
}, },
"storeTitle": "Toko Ekstensi", "storeTitle": "Repo Ekstensi",
"@storeTitle": { "@storeTitle": {
"description": "Store screen title" "description": "Store screen title"
}, },
"storeLoadError": "Gagal memuat repo",
"@storeLoadError": {
"description": "Error heading when the store cannot be loaded"
},
"storeSearch": "Cari ekstensi...", "storeSearch": "Cari ekstensi...",
"@storeSearch": { "@storeSearch": {
"description": "Store search placeholder" "description": "Store search placeholder"
@@ -2459,7 +2463,7 @@
"@tutorialExtensionsDesc": { "@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description" "description": "Tutorial extensions page description"
}, },
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions", "tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
"@tutorialExtensionsTip1": { "@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1" "description": "Tutorial extensions tip 1"
}, },
+6
View File
@@ -1,4 +1,5 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
part 'settings.g.dart'; part 'settings.g.dart';
@@ -12,6 +13,8 @@ class AppSettings {
final String downloadTreeUri; // SAF persistable tree URI final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback; final bool autoFallback;
final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding final bool embedMetadata; // Master switch for metadata/cover/lyrics embedding
final String
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
final bool embedLyrics; final bool embedLyrics;
final bool maxQualityCover; final bool maxQualityCover;
final bool isFirstLaunch; final bool isFirstLaunch;
@@ -88,6 +91,7 @@ class AppSettings {
this.downloadTreeUri = '', this.downloadTreeUri = '',
this.autoFallback = true, this.autoFallback = true,
this.embedMetadata = true, this.embedMetadata = true,
this.artistTagMode = artistTagModeJoined,
this.embedLyrics = true, this.embedLyrics = true,
this.maxQualityCover = true, this.maxQualityCover = true,
this.isFirstLaunch = true, this.isFirstLaunch = true,
@@ -152,6 +156,7 @@ class AppSettings {
String? downloadTreeUri, String? downloadTreeUri,
bool? autoFallback, bool? autoFallback,
bool? embedMetadata, bool? embedMetadata,
String? artistTagMode,
bool? embedLyrics, bool? embedLyrics,
bool? maxQualityCover, bool? maxQualityCover,
bool? isFirstLaunch, bool? isFirstLaunch,
@@ -210,6 +215,7 @@ class AppSettings {
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback, autoFallback: autoFallback ?? this.autoFallback,
embedMetadata: embedMetadata ?? this.embedMetadata, embedMetadata: embedMetadata ?? this.embedMetadata,
artistTagMode: artistTagMode ?? this.artistTagMode,
embedLyrics: embedLyrics ?? this.embedLyrics, embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover, maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
+2
View File
@@ -15,6 +15,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
downloadTreeUri: json['downloadTreeUri'] as String? ?? '', downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true, autoFallback: json['autoFallback'] as bool? ?? true,
embedMetadata: json['embedMetadata'] as bool? ?? true, embedMetadata: json['embedMetadata'] as bool? ?? true,
artistTagMode: json['artistTagMode'] as String? ?? 'joined',
embedLyrics: json['embedLyrics'] as bool? ?? true, embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true, maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
@@ -93,6 +94,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'downloadTreeUri': instance.downloadTreeUri, 'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback, 'autoFallback': instance.autoFallback,
'embedMetadata': instance.embedMetadata, 'embedMetadata': instance.embedMetadata,
'artistTagMode': instance.artistTagMode,
'embedLyrics': instance.embedLyrics, 'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover, 'maxQualityCover': instance.maxQualityCover,
'isFirstLaunch': instance.isFirstLaunch, 'isFirstLaunch': instance.isFirstLaunch,
+14 -10
View File
@@ -1559,7 +1559,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final isDownloading = itemProgress['is_downloading'] as bool? ?? false; final isDownloading = itemProgress['is_downloading'] as bool? ?? false;
final status = itemProgress['status'] as String? ?? 'downloading'; final status = itemProgress['status'] as String? ?? 'downloading';
if (status == 'finalizing' && bytesTotal > 0) { if (status == 'finalizing') {
progressUpdates[itemId] = const _ProgressUpdate( progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing, status: DownloadStatus.finalizing,
progress: 1.0, progress: 1.0,
@@ -2996,6 +2996,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? coverPath ? coverPath
: null, : null,
metadata: metadata, metadata: metadata,
artistTagMode: settings.artistTagMode,
); );
if (result != null) { if (result != null) {
@@ -3328,6 +3329,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? coverPath ? coverPath
: null, : null,
metadata: metadata, metadata: metadata,
artistTagMode: settings.artistTagMode,
); );
if (result != null) { if (result != null) {
@@ -3802,6 +3804,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
); );
var quality = item.qualityOverride ?? state.audioQuality; var quality = item.qualityOverride ?? state.audioQuality;
if (quality == 'DEFAULT') quality = state.audioQuality;
final isSafMode = _isSafMode(settings); final isSafMode = _isSafMode(settings);
final relativeOutputDir = isSafMode final relativeOutputDir = isSafMode
? await _buildRelativeOutputDir( ? await _buildRelativeOutputDir(
@@ -4214,6 +4217,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filenameFormat: state.filenameFormat, filenameFormat: state.filenameFormat,
quality: quality, quality: quality,
embedMetadata: metadataEmbeddingEnabled, embedMetadata: metadataEmbeddingEnabled,
artistTagMode: settings.artistTagMode,
embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics, embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics,
embedMaxQualityCover: embedMaxQualityCover:
metadataEmbeddingEnabled && settings.maxQualityCover, metadataEmbeddingEnabled && settings.maxQualityCover,
@@ -4358,7 +4362,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) { if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...'); _log.i('Encrypted stream detected, decrypting via FFmpeg...');
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9); updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9);
if (effectiveSafMode && isContentUri(filePath)) { if (effectiveSafMode && isContentUri(filePath)) {
final currentFilePath = filePath; final currentFilePath = filePath;
@@ -4503,7 +4507,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.95, progress: 0.95,
); );
@@ -4524,7 +4528,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Embedding metadata to $format...'); _log.i('Embedding metadata to $format...');
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.99, progress: 0.99,
); );
@@ -4608,7 +4612,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else { } else {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.95, progress: 0.95,
); );
flacPath = await FFmpegService.convertM4aToFlac(tempPath); flacPath = await FFmpegService.convertM4aToFlac(tempPath);
@@ -4684,7 +4688,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.95, progress: 0.95,
); );
@@ -4711,7 +4715,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Embedding metadata to $format...'); _log.i('Embedding metadata to $format...');
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.99, progress: 0.99,
); );
@@ -4765,7 +4769,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else { } else {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.95, progress: 0.95,
); );
final flacPath = await FFmpegService.convertM4aToFlac( final flacPath = await FFmpegService.convertM4aToFlac(
@@ -4849,7 +4853,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.99, progress: 0.99,
); );
@@ -4930,7 +4934,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try { try {
updateItemStatus( updateItemStatus(
item.id, item.id,
DownloadStatus.downloading, DownloadStatus.finalizing,
progress: 0.99, progress: 0.99,
); );
+115 -11
View File
@@ -20,6 +20,7 @@ final _prefs = SharedPreferences.getInstance();
class LocalLibraryState { class LocalLibraryState {
final List<LocalLibraryItem> items; final List<LocalLibraryItem> items;
final bool isScanning; final bool isScanning;
final bool scanIsFinalizing;
final double scanProgress; final double scanProgress;
final String? scanCurrentFile; final String? scanCurrentFile;
final int scanTotalFiles; final int scanTotalFiles;
@@ -35,6 +36,7 @@ class LocalLibraryState {
LocalLibraryState({ LocalLibraryState({
this.items = const [], this.items = const [],
this.isScanning = false, this.isScanning = false,
this.scanIsFinalizing = false,
this.scanProgress = 0, this.scanProgress = 0,
this.scanCurrentFile, this.scanCurrentFile,
this.scanTotalFiles = 0, this.scanTotalFiles = 0,
@@ -85,6 +87,7 @@ class LocalLibraryState {
LocalLibraryState copyWith({ LocalLibraryState copyWith({
List<LocalLibraryItem>? items, List<LocalLibraryItem>? items,
bool? isScanning, bool? isScanning,
bool? scanIsFinalizing,
double? scanProgress, double? scanProgress,
String? scanCurrentFile, String? scanCurrentFile,
int? scanTotalFiles, int? scanTotalFiles,
@@ -100,6 +103,7 @@ class LocalLibraryState {
return LocalLibraryState( return LocalLibraryState(
items: nextItems, items: nextItems,
isScanning: isScanning ?? this.isScanning, isScanning: isScanning ?? this.isScanning,
scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
scanProgress: scanProgress ?? this.scanProgress, scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile, scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles, scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
@@ -120,7 +124,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance; final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance;
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
static const _progressPollingInterval = Duration(milliseconds: 1200); static const _progressPollingInterval = Duration(milliseconds: 350);
static const _progressStreamBootstrapTimeout = Duration(milliseconds: 900);
Timer? _progressTimer; Timer? _progressTimer;
Timer? _progressStreamBootstrapTimer; Timer? _progressStreamBootstrapTimer;
StreamSubscription<Map<String, dynamic>>? _progressStreamSub; StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
@@ -220,6 +225,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
); );
state = state.copyWith( state = state.copyWith(
isScanning: true, isScanning: true,
scanIsFinalizing: false,
scanProgress: 0, scanProgress: 0,
scanCurrentFile: null, scanCurrentFile: null,
scanTotalFiles: 0, scanTotalFiles: 0,
@@ -297,11 +303,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
? await PlatformBridge.scanSafTree(effectiveFolderPath) ? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath); : await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
await _showScanCancelledNotification(); await _showScanCancelledNotification();
return; return;
} }
state = state.copyWith(
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
scanCurrentFile: null,
);
final items = <LocalLibraryItem>[]; final items = <LocalLibraryItem>[];
int skippedDownloads = 0; int skippedDownloads = 0;
for (final json in results) { for (final json in results) {
@@ -334,11 +350,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith( state = state.copyWith(
items: persistedItems, items: persistedItems,
isScanning: false, isScanning: false,
scanIsFinalizing: false,
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads, excludedDownloadedCount: skippedDownloads,
); );
await _pruneLibraryCoverCache(persistedItems);
_log.i( _log.i(
'Full scan complete: ${persistedItems.length} tracks found, ' 'Full scan complete: ${persistedItems.length} tracks found, '
@@ -403,11 +421,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
if (_scanCancelRequested) { if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
await _showScanCancelledNotification(); await _showScanCancelledNotification();
return; return;
} }
state = state.copyWith(
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
scanCurrentFile: null,
);
final scannedList = final scannedList =
(result['files'] as List<dynamic>?) ?? (result['files'] as List<dynamic>?) ??
(result['scanned'] as List<dynamic>?) ?? (result['scanned'] as List<dynamic>?) ??
@@ -497,6 +525,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith( state = state.copyWith(
items: items, items: items,
isScanning: false, isScanning: false,
scanIsFinalizing: false,
scanProgress: 100, scanProgress: 100,
lastScannedAt: now, lastScannedAt: now,
scanWasCancelled: false, scanWasCancelled: false,
@@ -516,7 +545,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
} }
} catch (e, stack) { } catch (e, stack) {
_log.e('Library scan failed: $e', e, stack); _log.e('Library scan failed: $e', e, stack);
state = state.copyWith(isScanning: false, scanWasCancelled: false); state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: false,
);
await _showScanFailedNotification(e.toString()); await _showScanFailedNotification(e.toString());
} finally { } finally {
if (didStartSecurityAccess) { if (didStartSecurityAccess) {
@@ -573,7 +606,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
cancelOnError: false, cancelOnError: false,
); );
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () { Future<void>.microtask(_requestProgressSnapshot);
_progressStreamBootstrapTimer = Timer(
_progressStreamBootstrapTimeout,
() {
if (_hasReceivedProgressStreamEvent) { if (_hasReceivedProgressStreamEvent) {
return; return;
} }
@@ -582,7 +619,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_progressStreamSub = null; _progressStreamSub = null;
_usingProgressStream = false; _usingProgressStream = false;
_startProgressPollingTimer(); _startProgressPollingTimer();
}); },
);
return; return;
} }
@@ -609,20 +647,41 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}); });
} }
Future<void> _requestProgressSnapshot() async {
if (_isProgressPollingInFlight) return;
_isProgressPollingInFlight = true;
try {
final progress = await PlatformBridge.getLibraryScanProgress();
await _handleLibraryScanProgress(progress);
_progressPollingErrorCount = 0;
} catch (e) {
_progressPollingErrorCount++;
if (_progressPollingErrorCount <= 3) {
_log.w('Initial library scan progress fetch failed: $e');
}
} finally {
_isProgressPollingInFlight = false;
}
}
Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async { Future<void> _handleLibraryScanProgress(Map<String, dynamic> progress) async {
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
0.0, 0.0,
100.0, 100.0,
); );
final isComplete = progress['is_complete'] == true;
final displayProgress = isComplete
? 99.0
: (normalizedProgress >= 100.0 ? 99.0 : normalizedProgress);
final currentFile = progress['current_file'] as String?; final currentFile = progress['current_file'] as String?;
final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0; final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0; final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0; final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
final isComplete = progress['is_complete'] == true;
final shouldUpdateState = final shouldUpdateState =
state.scanProgress != normalizedProgress || state.scanProgress != displayProgress ||
state.scanIsFinalizing != isComplete ||
state.scanCurrentFile != currentFile || state.scanCurrentFile != currentFile ||
state.scanTotalFiles != totalFiles || state.scanTotalFiles != totalFiles ||
state.scannedFiles != scannedFiles || state.scannedFiles != scannedFiles ||
@@ -630,8 +689,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (shouldUpdateState) { if (shouldUpdateState) {
state = state.copyWith( state = state.copyWith(
scanProgress: normalizedProgress, scanIsFinalizing: isComplete,
scanCurrentFile: currentFile, scanProgress: displayProgress,
scanCurrentFile: isComplete ? null : currentFile,
scanTotalFiles: totalFiles, scanTotalFiles: totalFiles,
scannedFiles: scannedFiles, scannedFiles: scannedFiles,
scanErrorCount: errorCount, scanErrorCount: errorCount,
@@ -704,7 +764,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Cancelling library scan'); _log.i('Cancelling library scan');
_scanCancelRequested = true; _scanCancelRequested = true;
await PlatformBridge.cancelLibraryScan(); await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false, scanWasCancelled: true); state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
_stopProgressPolling(); _stopProgressPolling();
await _showScanCancelledNotification(); await _showScanCancelledNotification();
} }
@@ -815,6 +879,46 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Library cleared'); _log.i('Library cleared');
} }
Future<void> _pruneLibraryCoverCache(Iterable<LocalLibraryItem> items) async {
try {
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
if (!await libraryCoverDir.exists()) {
return;
}
final referencedCoverPaths = items
.map((item) => item.coverPath)
.whereType<String>()
.where((path) => path.isNotEmpty)
.toSet();
var deletedCount = 0;
await for (final entity in libraryCoverDir.list(
recursive: true,
followLinks: false,
)) {
if (entity is! File || referencedCoverPaths.contains(entity.path)) {
continue;
}
try {
await entity.delete();
deletedCount++;
} catch (e) {
_log.w(
'Failed deleting stale library cover cache ${entity.path}: $e',
);
}
}
if (deletedCount > 0) {
_log.i('Pruned $deletedCount stale library cover cache files');
}
} catch (e) {
_log.w('Failed pruning library cover cache: $e');
}
}
Future<void> removeItem(String id) async { Future<void> removeItem(String id) async {
await _db.delete(id); await _db.delete(id);
state = state.copyWith( state = state.copyWith(
+8
View File
@@ -6,6 +6,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/constants/app_info.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
@@ -260,6 +261,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _saveSettings();
} }
void setArtistTagMode(String mode) {
if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) {
state = state.copyWith(artistTagMode: mode);
_saveSettings();
}
}
void setLyricsMode(String mode) { void setLyricsMode(String mode) {
if (mode == 'embed' || mode == 'external' || mode == 'both') { if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode); state = state.copyWith(lyricsMode: mode);
-79
View File
@@ -538,90 +538,11 @@ class TrackNotifier extends Notifier<TrackState> {
return; return;
} }
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
url.startsWith('spotify:');
if (!isSpotifyUrl) {
state = TrackState( state = TrackState(
isLoading: false, isLoading: false,
error: 'url_not_recognized', error: 'url_not_recognized',
hasSearchText: state.hasSearchText, hasSearchText: state.hasSearchText,
); );
return;
}
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
Map<String, dynamic> metadata;
try {
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
} catch (e) {
rethrow;
}
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
} catch (e) { } catch (e) {
if (!_isRequestValid(requestId)) return; if (!_isRequestValid(requestId)) return;
state = TrackState( state = TrackState(
+79 -14
View File
@@ -174,25 +174,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Future<void> _fetchTracks() async { Future<void> _fetchTracks() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) { if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', ''); final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata( final metadata = await PlatformBridge.getDeezerMetadata(
'album', 'album',
deezerAlbumId, deezerAlbumId,
); );
} else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId);
} else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId);
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
}
final trackList = metadata['track_list'] as List<dynamic>; final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>)) .map((t) => _parseTrack(t as Map<String, dynamic>))
@@ -211,6 +198,84 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
_isLoading = false; _isLoading = false;
}); });
} }
return;
} else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata(
'album',
qobuzAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
} else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata(
'album',
tidalAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
} else {
final url = 'https://open.spotify.com/album/${widget.albumId}';
final result = await PlatformBridge.handleURLWithExtension(url);
if (result == null || result['tracks'] == null) {
throw StateError('Failed to load album metadata from extension');
}
final trackList = result['tracks'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
}
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() { setState(() {
+1 -16
View File
@@ -343,13 +343,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
headerImage = artistData['header_image'] as String?; headerImage = artistData['header_image'] as String?;
listeners = artistData['listeners'] as int?; listeners = artistData['listeners'] as int?;
} else { } else {
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback( throw StateError('Failed to load artist metadata from extension');
url,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
} }
} }
@@ -1105,15 +1099,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album)) .map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList(); .toList();
} }
// Fallback to direct Spotify metadata
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} }
return []; return [];
} }
+19 -10
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -1164,19 +1165,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final shouldEmbedLyrics = final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external'; settings.embedLyrics && settings.lyricsMode != 'external';
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackConvertConverting,
total: total,
icon: Icons.transform,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (int i = 0; i < total; i++) { for (int i = 0; i < total; i++) {
if (!mounted) break; if (!mounted || cancelled) break;
final item = selected[i]; final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars(); BatchProgressDialog.update(current: i + 1, detail: item.trackName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
try { try {
final metadata = <String, String>{ final metadata = <String, String>{
@@ -1230,6 +1235,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bitrate: bitrate, bitrate: bitrate,
metadata: metadata, metadata: metadata,
coverPath: coverPath, coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf, deleteOriginal: !isSaf,
); );
@@ -1335,6 +1341,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_exitSelectionMode(); _exitSelectionMode();
if (mounted) { if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
+11 -12
View File
@@ -2313,7 +2313,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
error.contains('429') || error.contains('429') ||
error.toLowerCase().contains('rate limit') || error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests'); error.toLowerCase().contains('too many requests');
final isUrlNotRecognized = error == 'url_not_recognized'; final isUrlNotRecognized = error == 'url_not_recognized';
if (isRateLimit) { if (isRateLimit) {
@@ -3087,7 +3086,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extState = ref.read(extensionProvider); final extState = ref.read(extensionProvider);
if (!extState.isInitialized) { if (!extState.isInitialized) {
return 'Paste Spotify URL or search...'; return 'Paste supported URL or search...';
} }
if (searchProvider != null && searchProvider.isNotEmpty) { if (searchProvider != null && searchProvider.isNotEmpty) {
@@ -3108,7 +3107,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return 'Search with ${ext.displayName}...'; return 'Search with ${ext.displayName}...';
} }
} }
return 'Paste Spotify URL or search...'; return 'Paste supported URL or search...';
} }
Widget _buildSearchFilterBar( Widget _buildSearchFilterBar(
@@ -3125,7 +3124,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Padding( Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: FilterChip( child: FilterChip(
label: const Text('All'), label: Text(context.l10n.historyFilterAll),
selected: selectedFilter == null, selected: selectedFilter == null,
onSelected: (_) { onSelected: (_) {
ref.read(trackProvider.notifier).setSearchFilter(null); ref.read(trackProvider.notifier).setSearchFilter(null);
@@ -4213,7 +4212,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
if (result == null) { if (result == null) {
setState(() { setState(() {
_error = 'Failed to load album'; _error = context.l10n.errorLoadAlbum;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4222,7 +4221,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
final trackList = result['tracks'] as List<dynamic>?; final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) { if (trackList == null) {
setState(() { setState(() {
_error = 'No tracks found'; _error = context.l10n.errorNoTracksFound;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4244,7 +4243,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = 'Error: $e'; _error = context.l10n.snackbarError(e.toString());
_isLoading = false; _isLoading = false;
}); });
} }
@@ -4377,7 +4376,7 @@ class _ExtensionPlaylistScreenState
if (result == null) { if (result == null) {
setState(() { setState(() {
_error = 'Failed to load playlist'; _error = context.l10n.errorLoadPlaylist;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4386,7 +4385,7 @@ class _ExtensionPlaylistScreenState
final trackList = result['tracks'] as List<dynamic>?; final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) { if (trackList == null) {
setState(() { setState(() {
_error = 'No tracks found'; _error = context.l10n.errorNoTracksFound;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4403,7 +4402,7 @@ class _ExtensionPlaylistScreenState
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = 'Error: $e'; _error = context.l10n.snackbarError(e.toString());
_isLoading = false; _isLoading = false;
}); });
} }
@@ -4529,7 +4528,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
if (result == null) { if (result == null) {
setState(() { setState(() {
_error = 'Failed to load artist'; _error = context.l10n.errorLoadArtist;
_isLoading = false; _isLoading = false;
}); });
return; return;
@@ -4563,7 +4562,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_error = 'Error: $e'; _error = context.l10n.snackbarError(e.toString());
_isLoading = false; _isLoading = false;
}); });
} }
+16 -6
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/providers/playback_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/library_database.dart'; import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -1236,15 +1237,24 @@ class _CollectionTrackTile extends ConsumerWidget {
), ),
trailing: isSelectionMode trailing: isSelectionMode
? null ? null
: IconButton( : historyItem != null || localItem != null
tooltip: MaterialLocalizations.of(context).showMenuTooltip, ? IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () {
ref
.read(playbackProvider.notifier)
.playTrackList([track]);
},
icon: Icon( icon: Icon(
Icons.more_vert, Icons.play_arrow,
color: colorScheme.onSurfaceVariant, color: colorScheme.primary,
size: 20,
), ),
onPressed: () => _showTrackOptionsSheet(context, ref), style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer
.withValues(alpha: 0.3),
), ),
)
: null,
onTap: isSelectionMode onTap: isSelectionMode
? onTap ? onTap
: () { : () {
+60 -29
View File
@@ -13,6 +13,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/local_track_redownload_service.dart';
import 'package:spotiflac_android/widgets/batch_progress_dialog.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -816,6 +817,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
lowerPath.endsWith('.opus') || lowerPath.endsWith('.opus') ||
lowerPath.endsWith('.ogg'); lowerPath.endsWith('.ogg');
final artistTagMode = ref.read(settingsProvider).artistTagMode;
String? ffmpegResult; String? ffmpegResult;
if (isMp3) { if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3( ffmpegResult = await FFmpegService.embedMetadataToMp3(
@@ -834,6 +836,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
opusPath: ffmpegTarget, opusPath: ffmpegTarget,
coverPath: effectiveCoverPath, coverPath: effectiveCoverPath,
metadata: metadata, metadata: metadata,
artistTagMode: artistTagMode,
); );
} }
@@ -866,11 +869,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Future<bool> _reEnrichLocalTrack(LocalLibraryItem item) async { Future<bool> _reEnrichLocalTrack(LocalLibraryItem item) async {
final durationMs = (item.duration ?? 0) * 1000; final durationMs = (item.duration ?? 0) * 1000;
final artistTagMode = ref.read(settingsProvider).artistTagMode;
final request = <String, dynamic>{ final request = <String, dynamic>{
'file_path': item.filePath, 'file_path': item.filePath,
'cover_url': '', 'cover_url': '',
'max_quality': true, 'max_quality': true,
'embed_lyrics': true, 'embed_lyrics': true,
'artist_tag_mode': artistTagMode,
'spotify_id': '', 'spotify_id': '',
'track_name': item.trackName, 'track_name': item.trackName,
'artist_name': item.artistName, 'artist_name': item.artistName,
@@ -957,17 +962,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
var skippedCount = 0; var skippedCount = 0;
final total = selected.length; final total = selected.length;
for (var i = 0; i < total; i++) { var cancelled = false;
if (!mounted) break; BatchProgressDialog.show(
context: context,
ScaffoldMessenger.of(context).clearSnackBars(); title: context.l10n.queueFlacAction,
ScaffoldMessenger.of(context).showSnackBar( total: total,
SnackBar( icon: Icons.queue_music,
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)), onCancel: () {
duration: const Duration(seconds: 30), cancelled = true;
), BatchProgressDialog.dismiss(context);
},
); );
for (var i = 0; i < total; i++) {
if (!mounted || cancelled) break;
BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName);
try { try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch( final resolution = await LocalTrackRedownloadService.resolveBestMatch(
selected[i], selected[i],
@@ -987,7 +998,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return; return;
} }
ScaffoldMessenger.of(context).clearSnackBars(); if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
if (matchedTracks.isEmpty) { if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -1063,18 +1076,25 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
var successCount = 0; var successCount = 0;
final total = selected.length; final total = selected.length;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackReEnrichProgress,
total: total,
icon: Icons.auto_fix_high,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (var i = 0; i < total; i++) { for (var i = 0; i < total; i++) {
if (!mounted) break; if (!mounted || cancelled) break;
final item = selected[i]; final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars(); BatchProgressDialog.update(
ScaffoldMessenger.of(context).showSnackBar( current: i + 1,
SnackBar( detail: '${item.trackName} - ${item.artistName}',
content: Text(
'${context.l10n.trackReEnrichProgress} (${i + 1}/$total)',
),
duration: const Duration(seconds: 30),
),
); );
try { try {
@@ -1114,6 +1134,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return; return;
} }
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount; final failedCount = total - successCount;
final summary = failedCount <= 0 final summary = failedCount <= 0
@@ -1422,19 +1445,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final shouldEmbedLyrics = final shouldEmbedLyrics =
settings.embedLyrics && settings.lyricsMode != 'external'; settings.embedLyrics && settings.lyricsMode != 'external';
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.trackConvertConverting,
total: total,
icon: Icons.transform,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
for (int i = 0; i < total; i++) { for (int i = 0; i < total; i++) {
if (!mounted) break; if (!mounted || cancelled) break;
final item = selected[i]; final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars(); BatchProgressDialog.update(current: i + 1, detail: item.trackName);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
try { try {
final metadata = <String, String>{ final metadata = <String, String>{
@@ -1487,6 +1514,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bitrate: bitrate, bitrate: bitrate,
metadata: metadata, metadata: metadata,
coverPath: coverPath, coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf, deleteOriginal: !isSaf,
); );
@@ -1621,6 +1649,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_exitSelectionMode(); _exitSelectionMode();
if (mounted) { if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
+37 -29
View File
@@ -12,12 +12,13 @@ import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/providers/store_provider.dart';
import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart';
import 'package:spotiflac_android/screens/home_tab.dart'; import 'package:spotiflac_android/screens/home_tab.dart';
import 'package:spotiflac_android/screens/store_tab.dart'; import 'package:spotiflac_android/screens/repo_tab.dart';
import 'package:spotiflac_android/screens/queue_tab.dart'; import 'package:spotiflac_android/screens/queue_tab.dart';
import 'package:spotiflac_android/screens/settings/settings_tab.dart'; import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/services/update_checker.dart';
import 'package:spotiflac_android/widgets/update_dialog.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart';
@@ -44,8 +45,14 @@ class _MainShellState extends ConsumerState<MainShell>
ShellNavigationService.homeTabNavigatorKey; ShellNavigationService.homeTabNavigatorKey;
final GlobalKey<NavigatorState> _libraryTabNavigatorKey = final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
ShellNavigationService.libraryTabNavigatorKey; ShellNavigationService.libraryTabNavigatorKey;
final GlobalKey<NavigatorState> _storeTabNavigatorKey = final GlobalKey<NavigatorState> _repoTabNavigatorKey =
ShellNavigationService.storeTabNavigatorKey; ShellNavigationService.repoTabNavigatorKey;
@override
void didChangeDependencies() {
super.didChangeDependencies();
NotificationService().updateStrings(context.l10n);
}
@override @override
void initState() { void initState() {
@@ -58,7 +65,7 @@ class _MainShellState extends ConsumerState<MainShell>
); );
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: false, showRepoTab: false,
); );
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates(); _checkForUpdates();
@@ -268,7 +275,7 @@ class _MainShellState extends ConsumerState<MainShell>
); );
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: showStore, showRepoTab: showStore,
); );
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
// Jump directly when skipping intermediate tabs to avoid // Jump directly when skipping intermediate tabs to avoid
@@ -295,17 +302,17 @@ class _MainShellState extends ConsumerState<MainShell>
); );
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: showStore, showRepoTab: showStore,
); );
FocusManager.instance.primaryFocus?.unfocus(); FocusManager.instance.primaryFocus?.unfocus();
} }
} }
void _handleBackPress() { Future<void> _handleBackPress() async {
final rootNavigator = Navigator.of(context, rootNavigator: true); final rootNavigator = Navigator.of(context, rootNavigator: true);
if (rootNavigator.canPop()) { final handledByRootNavigator = await rootNavigator.maybePop();
_log.i('Back: step 1 - root navigator pop'); if (handledByRootNavigator) {
rootNavigator.pop(); _log.i('Back: step 1 - root navigator handled back');
_lastBackPress = null; _lastBackPress = null;
return; return;
} }
@@ -314,9 +321,10 @@ class _MainShellState extends ConsumerState<MainShell>
settingsProvider.select((s) => s.showExtensionStore), settingsProvider.select((s) => s.showExtensionStore),
); );
final currentNavigator = _navigatorForTab(_currentIndex, showStore); final currentNavigator = _navigatorForTab(_currentIndex, showStore);
if (currentNavigator != null && currentNavigator.canPop()) { final handledByCurrentNavigator =
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)'); await currentNavigator?.maybePop() ?? false;
currentNavigator.pop(); if (handledByCurrentNavigator) {
_log.i('Back: step 2 - tab navigator handled back (tab=$_currentIndex)');
_lastBackPress = null; _lastBackPress = null;
return; return;
} }
@@ -413,7 +421,7 @@ class _MainShellState extends ConsumerState<MainShell>
NavigatorState? _navigatorForTab(int index, bool showStore) { NavigatorState? _navigatorForTab(int index, bool showStore) {
if (index == 0) return _homeTabNavigatorKey.currentState; if (index == 0) return _homeTabNavigatorKey.currentState;
if (index == 1) return _libraryTabNavigatorKey.currentState; if (index == 1) return _libraryTabNavigatorKey.currentState;
if (showStore && index == 2) return _storeTabNavigatorKey.currentState; if (showStore && index == 2) return _repoTabNavigatorKey.currentState;
return null; return null;
} }
@@ -427,9 +435,9 @@ class _MainShellState extends ConsumerState<MainShell>
); );
ShellNavigationService.syncState( ShellNavigationService.syncState(
currentTabIndex: _currentIndex, currentTabIndex: _currentIndex,
showStoreTab: showStore, showRepoTab: showStore,
); );
final storeUpdatesCount = ref.watch( final repoUpdatesCount = ref.watch(
storeProvider.select((s) => s.updatesAvailableCount), storeProvider.select((s) => s.updatesAvailableCount),
); );
@@ -446,9 +454,9 @@ class _MainShellState extends ConsumerState<MainShell>
), ),
if (showStore) if (showStore)
_TabNavigator( _TabNavigator(
key: const ValueKey('tab-store'), key: const ValueKey('tab-repo'),
navigatorKey: _storeTabNavigatorKey, navigatorKey: _repoTabNavigatorKey,
child: const StoreTab(), child: const RepoTab(),
), ),
const SettingsTab(), const SettingsTab(),
]; ];
@@ -484,20 +492,20 @@ class _MainShellState extends ConsumerState<MainShell>
if (showStore) if (showStore)
NavigationDestination( NavigationDestination(
icon: AnimatedBadge( icon: AnimatedBadge(
count: storeUpdatesCount, count: repoUpdatesCount,
child: Badge( child: Badge(
isLabelVisible: storeUpdatesCount > 0, isLabelVisible: repoUpdatesCount > 0,
label: Text('$storeUpdatesCount'), label: Text('$repoUpdatesCount'),
child: const Icon(Icons.store_outlined), child: const Icon(Icons.extension_outlined),
), ),
), ),
selectedIcon: SwingIcon( selectedIcon: BouncingIcon(
child: AnimatedBadge( child: AnimatedBadge(
count: storeUpdatesCount, count: repoUpdatesCount,
child: Badge( child: Badge(
isLabelVisible: storeUpdatesCount > 0, isLabelVisible: repoUpdatesCount > 0,
label: Text('$storeUpdatesCount'), label: Text('$repoUpdatesCount'),
child: const Icon(Icons.store), child: const Icon(Icons.extension),
), ),
), ),
), ),
@@ -522,7 +530,7 @@ class _MainShellState extends ConsumerState<MainShell>
return BackButtonListener( return BackButtonListener(
onBackButtonPressed: () async { onBackButtonPressed: () async {
_handleBackPress(); await _handleBackPress();
return true; return true;
}, },
child: Scaffold( child: Scaffold(
File diff suppressed because it is too large Load Diff
@@ -8,14 +8,14 @@ import 'package:spotiflac_android/widgets/animation_utils.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'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget { class RepoTab extends ConsumerStatefulWidget {
const StoreTab({super.key}); const RepoTab({super.key});
@override @override
ConsumerState<StoreTab> createState() => _StoreTabState(); ConsumerState<RepoTab> createState() => _RepoTabState();
} }
class _StoreTabState extends ConsumerState<StoreTab> { class _RepoTabState extends ConsumerState<RepoTab> {
final _searchController = TextEditingController(); final _searchController = TextEditingController();
final _repoUrlController = TextEditingController(); final _repoUrlController = TextEditingController();
bool _isInitialized = false; bool _isInitialized = false;
@@ -323,7 +323,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
Icons.store_outlined, Icons.extension_outlined,
size: 72, size: 72,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -1359,7 +1359,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) { if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar( ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to open folder picker: $e'), content: Text(
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
),
backgroundColor: Theme.of(ctx).colorScheme.error, backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
+11 -9
View File
@@ -735,7 +735,7 @@ class _SearchProviderSelector extends ConsumerWidget {
(entry) => ListTile( (entry) => ListTile(
leading: Icon(Icons.search, color: colorScheme.tertiary), leading: Icon(Icons.search, color: colorScheme.tertiary),
title: Text(entry.value), title: Text(entry.value),
subtitle: Text('Search with ${entry.value}'), subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)),
trailing: settings.searchProvider == entry.key trailing: settings.searchProvider == entry.key
? Icon(Icons.check_circle, color: colorScheme.primary) ? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline), : Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -791,7 +791,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
final hasAnyProvider = homeFeedProviders.isNotEmpty; final hasAnyProvider = homeFeedProviders.isNotEmpty;
String currentProviderName = 'Auto'; String currentProviderName = context.l10n.extensionsHomeFeedAuto;
if (settings.homeFeedProvider != null && if (settings.homeFeedProvider != null &&
settings.homeFeedProvider!.isNotEmpty) { settings.homeFeedProvider!.isNotEmpty) {
final ext = homeFeedProviders final ext = homeFeedProviders
@@ -828,7 +828,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Home Feed Provider', context.l10n.extensionsHomeFeedProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: !hasAnyProvider ? colorScheme.outline : null, color: !hasAnyProvider ? colorScheme.outline : null,
), ),
@@ -836,7 +836,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
!hasAnyProvider !hasAnyProvider
? 'No extensions with home feed' ? context.l10n.extensionsNoHomeFeedExtensions
: currentProviderName, : currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@@ -883,7 +883,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text( child: Text(
'Home Feed Provider', ctx.l10n.extensionsHomeFeedProvider,
style: Theme.of( style: Theme.of(
context, context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -892,7 +892,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text( child: Text(
'Choose which extension provides the home feed on the main screen', ctx.l10n.extensionsHomeFeedDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -900,8 +900,8 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
), ),
ListTile( ListTile(
leading: Icon(Icons.auto_awesome, color: colorScheme.primary), leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
title: const Text('Auto'), title: Text(ctx.l10n.extensionsHomeFeedAuto),
subtitle: const Text('Automatically select the best available'), subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
trailing: trailing:
(settings.homeFeedProvider == null || (settings.homeFeedProvider == null ||
settings.homeFeedProvider!.isEmpty) settings.homeFeedProvider!.isEmpty)
@@ -917,7 +917,9 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
(ext) => ListTile( (ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary), leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName), title: Text(ext.displayName),
subtitle: Text('Use ${ext.displayName} home feed'), subtitle: Text(
ctx.l10n.extensionsHomeFeedUse(ext.displayName),
),
trailing: settings.homeFeedProvider == ext.id trailing: settings.homeFeedProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary) ? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline), : Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -392,6 +392,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
itemCount: libraryState.items.length, itemCount: libraryState.items.length,
excludedDownloadedCount: libraryState.excludedDownloadedCount, excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning, isScanning: libraryState.isScanning,
scanIsFinalizing: libraryState.scanIsFinalizing,
scanProgress: libraryState.scanProgress, scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile, scanCurrentFile: libraryState.scanCurrentFile,
scanTotalFiles: libraryState.scanTotalFiles, scanTotalFiles: libraryState.scanTotalFiles,
@@ -528,8 +529,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
children: [ children: [
if (libraryState.isScanning) if (libraryState.isScanning)
_ScanProgressTile( _ScanProgressTile(
isFinalizing: libraryState.scanIsFinalizing,
progress: libraryState.scanProgress, progress: libraryState.scanProgress,
currentFile: libraryState.scanCurrentFile, currentFile: libraryState.scanCurrentFile,
scannedFiles: libraryState.scannedFiles,
totalFiles: libraryState.scanTotalFiles, totalFiles: libraryState.scanTotalFiles,
onCancel: _cancelScan, onCancel: _cancelScan,
) )
@@ -646,6 +649,7 @@ class _LibraryHeroCard extends StatelessWidget {
final int itemCount; final int itemCount;
final int excludedDownloadedCount; final int excludedDownloadedCount;
final bool isScanning; final bool isScanning;
final bool scanIsFinalizing;
final double scanProgress; final double scanProgress;
final String? scanCurrentFile; final String? scanCurrentFile;
final int scanTotalFiles; final int scanTotalFiles;
@@ -656,6 +660,7 @@ class _LibraryHeroCard extends StatelessWidget {
required this.itemCount, required this.itemCount,
required this.excludedDownloadedCount, required this.excludedDownloadedCount,
required this.isScanning, required this.isScanning,
required this.scanIsFinalizing,
required this.scanProgress, required this.scanProgress,
this.scanCurrentFile, this.scanCurrentFile,
required this.scanTotalFiles, required this.scanTotalFiles,
@@ -680,6 +685,11 @@ 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 showIndeterminateProgress =
isScanning &&
(scanIsFinalizing ||
scanTotalFiles <= 0 ||
(scannedFiles <= 0 && scanProgress <= 0));
final displayCount = isScanning final displayCount = isScanning
? scannedFiles ? scannedFiles
: itemCount + excludedDownloadedCount; : itemCount + excludedDownloadedCount;
@@ -798,7 +808,7 @@ class _LibraryHeroCard extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
isScanning isScanning
? context.l10n.libraryTracksUnit(scannedFiles) ? context.l10n.libraryFilesUnit(scannedFiles)
: context.l10n.libraryTracksUnit(displayCount), : context.l10n.libraryTracksUnit(displayCount),
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
@@ -821,14 +831,49 @@ class _LibraryHeroCard extends StatelessWidget {
), ),
), ),
], ],
if (isScanning && scanCurrentFile != null) ...[ if (isScanning) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
LinearProgressIndicator( LinearProgressIndicator(
value: scanProgress / 100, value: showIndeterminateProgress
? null
: scanProgress / 100,
backgroundColor: colorScheme.surfaceContainerHighest, backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary, color: colorScheme.primary,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
const SizedBox(height: 8),
Text(
scanIsFinalizing
? context.l10n.libraryScanFinalizing
: scanTotalFiles > 0
? context.l10n.libraryScanProgress(
scanProgress.toStringAsFixed(0),
scanTotalFiles,
)
: context.l10n.libraryScanning,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.8,
),
),
),
if (!scanIsFinalizing &&
scanCurrentFile != null &&
scanCurrentFile!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
scanCurrentFile!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.7,
),
),
),
],
] else ...[ ] else ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@@ -865,14 +910,18 @@ class _LibraryHeroCard extends StatelessWidget {
} }
class _ScanProgressTile extends StatelessWidget { class _ScanProgressTile extends StatelessWidget {
final bool isFinalizing;
final double progress; final double progress;
final String? currentFile; final String? currentFile;
final int scannedFiles;
final int totalFiles; final int totalFiles;
final VoidCallback onCancel; final VoidCallback onCancel;
const _ScanProgressTile({ const _ScanProgressTile({
required this.isFinalizing,
required this.progress, required this.progress,
this.currentFile, this.currentFile,
required this.scannedFiles,
required this.totalFiles, required this.totalFiles,
required this.onCancel, required this.onCancel,
}); });
@@ -880,6 +929,8 @@ class _ScanProgressTile 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 showIndeterminateProgress =
isFinalizing || totalFiles <= 0 || (scannedFiles <= 0 && progress <= 0);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -901,10 +952,14 @@ class _ScanProgressTile extends StatelessWidget {
), ),
), ),
Text( Text(
context.l10n.libraryScanProgress( isFinalizing
? context.l10n.libraryScanFinalizing
: totalFiles > 0
? context.l10n.libraryScanProgress(
progress.toStringAsFixed(0), progress.toStringAsFixed(0),
totalFiles, totalFiles,
), )
: context.l10n.libraryScanning,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
@@ -920,12 +975,14 @@ class _ScanProgressTile extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
LinearProgressIndicator( LinearProgressIndicator(
value: progress / 100, value: showIndeterminateProgress ? null : progress / 100,
backgroundColor: colorScheme.surfaceContainerHighest, backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary, color: colorScheme.primary,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
if (currentFile != null) ...[ if (!isFinalizing &&
currentFile != null &&
currentFile!.trim().isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
currentFile!, currentFile!,
+3 -3
View File
@@ -519,7 +519,7 @@ class _LogEntryTile extends StatelessWidget {
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
entry.message, entry.previewMessage,
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
fontFamily: 'monospace', fontFamily: 'monospace',
@@ -527,10 +527,10 @@ class _LogEntryTile extends StatelessWidget {
height: 1.4, height: 1.4,
), ),
), ),
if (entry.error != null) ...[ if (entry.previewError != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
entry.error!, entry.previewError!,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontFamily: 'monospace', fontFamily: 'monospace',
+103 -5
View File
@@ -2,9 +2,10 @@ 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/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/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/artist_utils.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 {
@@ -115,6 +116,21 @@ class OptionsSettingsPage extends ConsumerWidget {
value: settings.embedMetadata, value: settings.embedMetadata,
onChanged: (v) => onChanged: (v) =>
ref.read(settingsProvider.notifier).setEmbedMetadata(v), ref.read(settingsProvider.notifier).setEmbedMetadata(v),
showDivider: settings.embedMetadata,
),
if (settings.embedMetadata)
SettingsItem(
icon: Icons.people_alt_outlined,
title: context.l10n.optionsArtistTagMode,
subtitle: _getArtistTagModeLabel(
context,
settings.artistTagMode,
),
onTap: () => _showArtistTagModePicker(
context,
ref,
settings.artistTagMode,
),
), ),
SettingsSwitchItem( SettingsSwitchItem(
icon: Icons.image, icon: Icons.image,
@@ -158,7 +174,7 @@ class OptionsSettingsPage extends ConsumerWidget {
child: SettingsGroup( child: SettingsGroup(
children: [ children: [
SettingsSwitchItem( SettingsSwitchItem(
icon: Icons.store, icon: Icons.extension,
title: context.l10n.optionsExtensionStore, title: context.l10n.optionsExtensionStore,
subtitle: context.l10n.optionsExtensionStoreSubtitle, subtitle: context.l10n.optionsExtensionStoreSubtitle,
value: settings.showExtensionStore, value: settings.showExtensionStore,
@@ -236,6 +252,88 @@ class OptionsSettingsPage extends ConsumerWidget {
); );
} }
String _getArtistTagModeLabel(BuildContext context, String mode) {
switch (mode) {
case artistTagModeSplitVorbis:
return context.l10n.optionsArtistTagModeSplitVorbis;
default:
return context.l10n.optionsArtistTagModeJoined;
}
}
void _showArtistTagModePicker(
BuildContext context,
WidgetRef ref,
String currentMode,
) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
context.l10n.optionsArtistTagMode,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
context.l10n.optionsArtistTagModeDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
ListTile(
leading: const Icon(Icons.segment_outlined),
title: Text(context.l10n.optionsArtistTagModeJoined),
subtitle: Text(context.l10n.optionsArtistTagModeJoinedSubtitle),
trailing: currentMode == artistTagModeJoined
? const Icon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setArtistTagMode(artistTagModeJoined);
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.library_music_outlined),
title: Text(context.l10n.optionsArtistTagModeSplitVorbis),
subtitle: Text(
context.l10n.optionsArtistTagModeSplitVorbisSubtitle,
),
trailing: currentMode == artistTagModeSplitVorbis
? const Icon(Icons.check)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setArtistTagMode(artistTagModeSplitVorbis);
Navigator.pop(context);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showClearHistoryDialog( void _showClearHistoryDialog(
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
@@ -307,9 +405,9 @@ class OptionsSettingsPage extends ConsumerWidget {
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
Navigator.pop(context); // Close loading dialog Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
).showSnackBar(SnackBar(content: Text('Error: $e'))); );
} }
} }
} }
+6 -4
View File
@@ -339,7 +339,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Failed to open folder picker: $e'), content: Text(
context.l10n.snackbarFolderPickerFailed(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error, backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4), duration: const Duration(seconds: 4),
), ),
@@ -430,9 +432,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) context.go('/tutorial'); if (mounted) context.go('/tutorial');
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
).showSnackBar(SnackBar(content: Text('Error: $e'))); );
} }
} finally { } finally {
setState(() => _isLoading = false); setState(() => _isLoading = false);
+18 -8
View File
@@ -1838,6 +1838,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
} }
} catch (_) {} } catch (_) {}
final artistTagMode = ref.read(settingsProvider).artistTagMode;
String? ffmpegResult; String? ffmpegResult;
if (isMp3) { if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3( ffmpegResult = await FFmpegService.embedMetadataToMp3(
@@ -1856,6 +1857,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
opusPath: workingPath, opusPath: workingPath,
coverPath: coverPath, coverPath: coverPath,
metadata: metadata, metadata: metadata,
artistTagMode: artistTagMode,
); );
} }
@@ -2228,6 +2230,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!_fileExists) return; if (!_fileExists) return;
try { try {
final artistTagMode = ref.read(settingsProvider).artistTagMode;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSearching)), SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
); );
@@ -2238,6 +2241,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'cover_url': _coverUrl ?? '', 'cover_url': _coverUrl ?? '',
'max_quality': true, 'max_quality': true,
'embed_lyrics': true, 'embed_lyrics': true,
'artist_tag_mode': artistTagMode,
'spotify_id': _spotifyId ?? '', 'spotify_id': _spotifyId ?? '',
'track_name': trackName, 'track_name': trackName,
'artist_name': artistName, 'artist_name': artistName,
@@ -2340,6 +2344,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
opusPath: ffmpegTarget, opusPath: ffmpegTarget,
coverPath: effectiveCoverPath, coverPath: effectiveCoverPath,
metadata: metadata, metadata: metadata,
artistTagMode: artistTagMode,
); );
} }
@@ -3554,6 +3559,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bitrate: bitrate, bitrate: bitrate,
metadata: metadata, metadata: metadata,
coverPath: coverPath, coverPath: coverPath,
artistTagMode: ref.read(settingsProvider).artistTagMode,
deleteOriginal: !isSaf, deleteOriginal: !isSaf,
); );
@@ -3768,6 +3774,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
initialValues: initialValues, initialValues: initialValues,
filePath: cleanFilePath, filePath: cleanFilePath,
sourceTrackId: _spotifyId, sourceTrackId: _spotifyId,
artistTagMode: ref.read(settingsProvider).artistTagMode,
), ),
); );
@@ -3989,12 +3996,14 @@ class _EditMetadataSheet extends StatefulWidget {
final Map<String, String> initialValues; final Map<String, String> initialValues;
final String filePath; final String filePath;
final String? sourceTrackId; final String? sourceTrackId;
final String artistTagMode;
const _EditMetadataSheet({ const _EditMetadataSheet({
required this.colorScheme, required this.colorScheme,
required this.initialValues, required this.initialValues,
required this.filePath, required this.filePath,
this.sourceTrackId, this.sourceTrackId,
required this.artistTagMode,
}); });
@override @override
@@ -4875,6 +4884,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'composer': _composerCtrl.text, 'composer': _composerCtrl.text,
'comment': _commentCtrl.text, 'comment': _commentCtrl.text,
'cover_path': _selectedCoverPath ?? '', 'cover_path': _selectedCoverPath ?? '',
'artist_tag_mode': widget.artistTagMode,
}; };
try { try {
@@ -5005,6 +5015,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
opusPath: ffmpegTarget, opusPath: ffmpegTarget,
coverPath: existingCoverPath, coverPath: existingCoverPath,
metadata: vorbisMap, metadata: vorbisMap,
artistTagMode: widget.artistTagMode,
); );
} }
@@ -5025,9 +5036,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (ffmpegResult == null) { if (ffmpegResult == null) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(content: Text(context.l10n.metadataSaveFailedFfmpeg)),
content: Text('Failed to save metadata via FFmpeg'),
),
); );
} }
setState(() => _saving = false); setState(() => _saving = false);
@@ -5038,9 +5047,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) { if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(content: Text(context.l10n.metadataSaveFailedStorage)),
content: Text('Failed to write metadata back to storage'),
),
); );
setState(() => _saving = false); setState(() => _saving = false);
return; return;
@@ -5094,7 +5101,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
'Edit Metadata', context.l10n.trackEditMetadata,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@@ -5107,7 +5114,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
else else
FilledButton(onPressed: _save, child: const Text('Save')), FilledButton(
onPressed: _save,
child: Text(context.l10n.dialogSave),
),
], ],
), ),
), ),
+1 -1
View File
@@ -185,7 +185,7 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
title: l10n.tutorialExtensionsTitle, title: l10n.tutorialExtensionsTitle,
description: l10n.tutorialExtensionsDesc, description: l10n.tutorialExtensionsDesc,
content: _buildFeatureList(context, [ content: _buildFeatureList(context, [
(Icons.storefront_rounded, l10n.tutorialExtensionsTip1), (Icons.extension_rounded, l10n.tutorialExtensionsTip1),
( (
Icons.add_circle_outline_rounded, Icons.add_circle_outline_rounded,
l10n.tutorialExtensionsTip2, l10n.tutorialExtensionsTip2,
@@ -11,6 +11,7 @@ class DownloadRequestPayload {
final String filenameFormat; final String filenameFormat;
final String quality; final String quality;
final bool embedMetadata; final bool embedMetadata;
final String artistTagMode;
final bool embedLyrics; final bool embedLyrics;
final bool embedMaxQualityCover; final bool embedMaxQualityCover;
final int trackNumber; final int trackNumber;
@@ -49,6 +50,7 @@ class DownloadRequestPayload {
required this.filenameFormat, required this.filenameFormat,
this.quality = 'LOSSLESS', this.quality = 'LOSSLESS',
this.embedMetadata = true, this.embedMetadata = true,
this.artistTagMode = 'joined',
this.embedLyrics = true, this.embedLyrics = true,
this.embedMaxQualityCover = true, this.embedMaxQualityCover = true,
this.trackNumber = 1, this.trackNumber = 1,
@@ -89,6 +91,7 @@ class DownloadRequestPayload {
'filename_format': filenameFormat, 'filename_format': filenameFormat,
'quality': quality, 'quality': quality,
'embed_metadata': embedMetadata, 'embed_metadata': embedMetadata,
'artist_tag_mode': artistTagMode,
'embed_lyrics': embedLyrics, 'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover, 'embed_max_quality_cover': embedMaxQualityCover,
'track_number': trackNumber, 'track_number': trackNumber,
@@ -133,6 +136,7 @@ class DownloadRequestPayload {
filenameFormat: filenameFormat, filenameFormat: filenameFormat,
quality: quality, quality: quality,
embedMetadata: embedMetadata, embedMetadata: embedMetadata,
artistTagMode: artistTagMode,
embedLyrics: embedLyrics, embedLyrics: embedLyrics,
embedMaxQualityCover: embedMaxQualityCover, embedMaxQualityCover: embedMaxQualityCover,
trackNumber: trackNumber, trackNumber: trackNumber,
+117 -20
View File
@@ -7,6 +7,7 @@ import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_session.dart';
import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; import 'package:ffmpeg_kit_flutter_new_full/return_code.dart';
import 'package:ffmpeg_kit_flutter_new_full/session_state.dart'; import 'package:ffmpeg_kit_flutter_new_full/session_state.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg'); final _log = AppLogger('FFmpeg');
@@ -887,6 +888,7 @@ class FFmpegService {
required String flacPath, required String flacPath,
String? coverPath, String? coverPath,
Map<String, String>? metadata, Map<String, String>? metadata,
String artistTagMode = artistTagModeJoined,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac'); final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
@@ -911,10 +913,11 @@ class FFmpegService {
cmdBuffer.write('-c:a copy '); cmdBuffer.write('-c:a copy ');
if (metadata != null) { if (metadata != null) {
metadata.forEach((key, value) { _appendVorbisMetadataToCommandBuffer(
final sanitizedValue = value.replaceAll('"', '\\"'); cmdBuffer,
cmdBuffer.write('-metadata $key="$sanitizedValue" '); metadata,
}); artistTagMode: artistTagMode,
);
} }
cmdBuffer.write('"$tempOutput" -y'); cmdBuffer.write('"$tempOutput" -y');
@@ -1046,6 +1049,7 @@ class FFmpegService {
required String opusPath, required String opusPath,
String? coverPath, String? coverPath,
Map<String, String>? metadata, Map<String, String>? metadata,
String artistTagMode = artistTagModeJoined,
}) async { }) async {
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus'); final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
@@ -1063,11 +1067,11 @@ class FFmpegService {
]; ];
if (metadata != null) { if (metadata != null) {
metadata.forEach((key, value) { _appendVorbisMetadataToArguments(
arguments arguments,
..add('-metadata') metadata,
..add('$key=$value'); artistTagMode: artistTagMode,
}); );
} }
if (coverPath != null) { if (coverPath != null) {
@@ -1154,8 +1158,11 @@ class FFmpegService {
// For M4A/MP4, cover art is mapped as a video stream and stored in the // For M4A/MP4, cover art is mapped as a video stream and stored in the
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic' // 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
// flag is only valid for Matroska/WebM containers and must NOT be used here. // flag is only valid for Matroska/WebM containers and must NOT be used here.
// Force the mp4 muxer when cover art is present because the default ipod
// muxer (auto-selected for .m4a) does not register a codec tag for mjpeg,
// causing "codec not currently supported in container" on FFmpeg 8.0+.
if (hasCover) { if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy '); cmdBuffer.write('-map 1:v -c:v copy -f mp4 ');
} }
cmdBuffer.write('-c:a copy '); cmdBuffer.write('-c:a copy ');
@@ -1326,6 +1333,7 @@ class FFmpegService {
required String bitrate, required String bitrate,
required Map<String, String> metadata, required Map<String, String> metadata,
String? coverPath, String? coverPath,
String artistTagMode = artistTagModeJoined,
bool deleteOriginal = true, bool deleteOriginal = true,
}) async { }) async {
final format = targetFormat.toLowerCase(); final format = targetFormat.toLowerCase();
@@ -1348,6 +1356,7 @@ class FFmpegService {
inputPath: inputPath, inputPath: inputPath,
metadata: metadata, metadata: metadata,
coverPath: coverPath, coverPath: coverPath,
artistTagMode: artistTagMode,
deleteOriginal: deleteOriginal, deleteOriginal: deleteOriginal,
); );
} }
@@ -1391,6 +1400,7 @@ class FFmpegService {
opusPath: outputPath, opusPath: outputPath,
coverPath: coverPath, coverPath: coverPath,
metadata: metadata, metadata: metadata,
artistTagMode: artistTagMode,
); );
} }
@@ -1446,10 +1456,10 @@ class FFmpegService {
} }
cmdBuffer.write('-map 0:a '); cmdBuffer.write('-map 0:a ');
// M4A/MP4 containers store cover art in the 'covr' atom automatically.
// '-disposition attached_pic' is only for Matroska/WebM and must NOT be used here.
if (hasCover) { if (hasCover) {
cmdBuffer.write('-map 1:v -c:v copy '); cmdBuffer.write('-map 1:v -c:v copy -disposition:v:0 attached_pic ');
cmdBuffer.write('-metadata:s:v title="Album cover" ');
cmdBuffer.write('-metadata:s:v comment="Cover (front)" ');
} }
cmdBuffer.write('-c:a alac '); cmdBuffer.write('-c:a alac ');
cmdBuffer.write('-map_metadata -1 '); cmdBuffer.write('-map_metadata -1 ');
@@ -1491,6 +1501,7 @@ class FFmpegService {
required String inputPath, required String inputPath,
required Map<String, String> metadata, required Map<String, String> metadata,
String? coverPath, String? coverPath,
String artistTagMode = artistTagModeJoined,
bool deleteOriginal = true, bool deleteOriginal = true,
}) async { }) async {
final outputPath = _buildOutputPath(inputPath, '.flac'); final outputPath = _buildOutputPath(inputPath, '.flac');
@@ -1515,11 +1526,11 @@ class FFmpegService {
cmdBuffer.write('-c:a flac -compression_level 8 '); cmdBuffer.write('-c:a flac -compression_level 8 ');
cmdBuffer.write('-map_metadata 0 '); cmdBuffer.write('-map_metadata 0 ');
final vorbisComments = _normalizeToVorbisComments(metadata); _appendVorbisMetadataToCommandBuffer(
for (final entry in vorbisComments.entries) { cmdBuffer,
final sanitized = entry.value.replaceAll('"', '\\"'); metadata,
cmdBuffer.write('-metadata ${entry.key}="$sanitized" '); artistTagMode: artistTagMode,
} );
cmdBuffer.write('"$outputPath" -y'); cmdBuffer.write('"$outputPath" -y');
@@ -1617,6 +1628,86 @@ class FFmpegService {
return vorbis; return vorbis;
} }
static void _appendVorbisMetadataToCommandBuffer(
StringBuffer cmdBuffer,
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
}) {
for (final entry in _buildVorbisMetadataEntries(
metadata,
artistTagMode: artistTagMode,
)) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
}
static void _appendVorbisMetadataToArguments(
List<String> arguments,
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
}) {
for (final entry in _buildVorbisMetadataEntries(
metadata,
artistTagMode: artistTagMode,
)) {
arguments
..add('-metadata')
..add('${entry.key}=${entry.value}');
}
}
static List<MapEntry<String, String>> _buildVorbisMetadataEntries(
Map<String, String> metadata, {
String artistTagMode = artistTagModeJoined,
}) {
final vorbis = _normalizeToVorbisComments(metadata);
final entries = <MapEntry<String, String>>[];
for (final entry in vorbis.entries) {
if (entry.key == 'ARTIST' || entry.key == 'ALBUMARTIST') {
continue;
}
entries.add(entry);
}
_appendVorbisArtistEntries(
entries,
'ARTIST',
vorbis['ARTIST'],
artistTagMode: artistTagMode,
);
_appendVorbisArtistEntries(
entries,
'ALBUMARTIST',
vorbis['ALBUMARTIST'],
artistTagMode: artistTagMode,
);
return entries;
}
static void _appendVorbisArtistEntries(
List<MapEntry<String, String>> entries,
String key,
String? rawValue, {
String artistTagMode = artistTagModeJoined,
}) {
final value = rawValue?.trim() ?? '';
if (value.isEmpty) {
return;
}
if (!shouldSplitVorbisArtistTags(artistTagMode)) {
entries.add(MapEntry(key, value));
return;
}
for (final artist in splitArtistTagValues(value)) {
entries.add(MapEntry(key, artist));
}
}
/// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg. /// Map Vorbis comment keys to M4A/MP4 metadata tag names for FFmpeg.
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) { static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
final m4aMap = <String, String>{}; final m4aMap = <String, String>{};
@@ -1691,6 +1782,9 @@ class FFmpegService {
final key = entry.key.toUpperCase(); final key = entry.key.toUpperCase();
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), ''); final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value; final value = entry.value;
if (value.trim().isEmpty) {
continue;
}
switch (normalizedKey) { switch (normalizedKey) {
case 'TITLE': case 'TITLE':
@@ -1708,12 +1802,16 @@ class FFmpegService {
case 'TRACKNUMBER': case 'TRACKNUMBER':
case 'TRACK': case 'TRACK':
case 'TRCK': case 'TRCK':
if (value != '0') {
id3Map['track'] = value; id3Map['track'] = value;
}
break; break;
case 'DISCNUMBER': case 'DISCNUMBER':
case 'DISC': case 'DISC':
case 'TPOS': case 'TPOS':
if (value != '0') {
id3Map['disc'] = value; id3Map['disc'] = value;
}
break; break;
case 'DATE': case 'DATE':
case 'YEAR': case 'YEAR':
@@ -1836,8 +1934,7 @@ class FFmpegService {
continue; continue;
} }
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') { if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {}
}
outputPaths.add(outputPath); outputPaths.add(outputPath);
_log.i('CUE split: track ${track.number} -> $outputFileName'); _log.i('CUE split: track ${track.number} -> $outputFileName');
+61 -26
View File
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
class NotificationService { class NotificationService {
static final NotificationService _instance = NotificationService._internal(); static final NotificationService _instance = NotificationService._internal();
@@ -13,6 +14,13 @@ class NotificationService {
FlutterLocalNotificationsPlugin(); FlutterLocalNotificationsPlugin();
bool _isInitialized = false; bool _isInitialized = false;
bool _notificationPermissionRequested = false; bool _notificationPermissionRequested = false;
AppLocalizations? _l10n;
/// Call this from the widget tree (e.g. didChangeDependencies) whenever the
/// app locale changes so that notification strings stay in sync.
void updateStrings(AppLocalizations l10n) {
_l10n = l10n;
}
static const int downloadProgressId = 1; static const int downloadProgressId = 1;
static const int updateDownloadId = 2; static const int updateDownloadId = 2;
@@ -165,7 +173,8 @@ class NotificationService {
await _showSafely( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: 'Downloading $trackName', title:
_l10n?.notifDownloadingTrack(trackName) ?? 'Downloading $trackName',
body: '$artistName$percentage%', body: '$artistName$percentage%',
details: details, details: details,
); );
@@ -208,8 +217,9 @@ class NotificationService {
await _showSafely( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: 'Finalizing $trackName', title: _l10n?.notifFinalizingTrack(trackName) ?? 'Finalizing $trackName',
body: '$artistName • Embedding metadata...', body:
'$artistName${_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...'}',
details: details, details: details,
); );
} }
@@ -226,12 +236,14 @@ class NotificationService {
String title; String title;
if (alreadyInLibrary) { if (alreadyInLibrary) {
title = completedCount != null && totalCount != null title = completedCount != null && totalCount != null
? 'Already in Library ($completedCount/$totalCount)' ? (_l10n?.notifAlreadyInLibraryCount(completedCount, totalCount) ??
: 'Already in Library'; 'Already in Library ($completedCount/$totalCount)')
: (_l10n?.notifAlreadyInLibrary ?? 'Already in Library');
} else { } else {
title = completedCount != null && totalCount != null title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)' ? (_l10n?.notifDownloadCompleteCount(completedCount, totalCount) ??
: 'Download Complete'; 'Download Complete ($completedCount/$totalCount)')
: (_l10n?.notifDownloadComplete ?? 'Download Complete');
} }
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
@@ -271,8 +283,9 @@ class NotificationService {
if (!_isInitialized) await initialize(); if (!_isInitialized) await initialize();
final title = failedCount > 0 final title = failedCount > 0
? 'Downloads Finished ($completedCount done, $failedCount failed)' ? (_l10n?.notifDownloadsFinished(completedCount, failedCount) ??
: 'All Downloads Complete'; 'Downloads Finished ($completedCount done, $failedCount failed)')
: (_l10n?.notifAllDownloadsComplete ?? 'All Downloads Complete');
const androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
channelId, channelId,
@@ -299,7 +312,9 @@ class NotificationService {
await _showSafely( await _showSafely(
id: downloadProgressId, id: downloadProgressId,
title: title, title: title,
body: '$completedCount tracks downloaded successfully', body:
_l10n?.notifTracksDownloadedSuccess(completedCount) ??
'$completedCount tracks downloaded successfully',
details: details, details: details,
); );
} }
@@ -319,8 +334,14 @@ class NotificationService {
final clampedProgress = progress.clamp(0.0, 100.0); final clampedProgress = progress.clamp(0.0, 100.0);
final percentage = clampedProgress.round(); final percentage = clampedProgress.round();
final progressBody = totalFiles > 0 final progressBody = totalFiles > 0
? '$scannedFiles/$totalFiles files • $percentage%' ? (_l10n?.notifLibraryScanProgressWithTotal(
: '$scannedFiles files scanned • $percentage%'; scannedFiles,
totalFiles,
percentage,
) ??
'$scannedFiles/$totalFiles files • $percentage%')
: (_l10n?.notifLibraryScanProgressNoTotal(scannedFiles, percentage) ??
'$scannedFiles files scanned • $percentage%');
final body = (currentFile != null && currentFile.isNotEmpty) final body = (currentFile != null && currentFile.isNotEmpty)
? '$progressBody\n$currentFile' ? '$progressBody\n$currentFile'
: progressBody; : progressBody;
@@ -355,7 +376,7 @@ class NotificationService {
await _showSafely( await _showSafely(
id: libraryScanId, id: libraryScanId,
title: 'Scanning local library', title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
body: body, body: body,
details: details, details: details,
); );
@@ -370,10 +391,15 @@ class NotificationService {
final extras = <String>[]; final extras = <String>[];
if (excludedDownloadedCount > 0) { if (excludedDownloadedCount > 0) {
extras.add('$excludedDownloadedCount excluded'); extras.add(
_l10n?.notifLibraryScanExcluded(excludedDownloadedCount) ??
'$excludedDownloadedCount excluded',
);
} }
if (errorCount > 0) { if (errorCount > 0) {
extras.add('$errorCount errors'); extras.add(
_l10n?.notifLibraryScanErrors(errorCount) ?? '$errorCount errors',
);
} }
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})'; final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
@@ -401,8 +427,9 @@ class NotificationService {
await _showSafely( await _showSafely(
id: libraryScanId, id: libraryScanId,
title: 'Library scan complete', title: _l10n?.notifLibraryScanComplete ?? 'Library scan complete',
body: '$totalTracks tracks indexed$suffix', body:
'${_l10n?.notifLibraryScanCompleteBody(totalTracks) ?? '$totalTracks tracks indexed'}$suffix',
details: details, details: details,
); );
} }
@@ -434,7 +461,7 @@ class NotificationService {
await _showSafely( await _showSafely(
id: libraryScanId, id: libraryScanId,
title: 'Library scan failed', title: _l10n?.notifLibraryScanFailed ?? 'Library scan failed',
body: message, body: message,
details: details, details: details,
); );
@@ -467,8 +494,8 @@ class NotificationService {
await _showSafely( await _showSafely(
id: libraryScanId, id: libraryScanId,
title: 'Library scan cancelled', title: _l10n?.notifLibraryScanCancelled ?? 'Library scan cancelled',
body: 'Scan stopped before completion.', body: _l10n?.notifLibraryScanStopped ?? 'Scan stopped before completion.',
details: details, details: details,
); );
} }
@@ -518,8 +545,12 @@ class NotificationService {
await _showSafely( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version', title:
body: '$receivedMB / $totalMB MB • $percentage%', _l10n?.notifDownloadingUpdate(version) ??
'Downloading SpotiFLAC v$version',
body:
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
'$receivedMB / $totalMB MB • $percentage%',
details: details, details: details,
); );
} }
@@ -551,8 +582,10 @@ class NotificationService {
await _showSafely( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Update Ready', title: _l10n?.notifUpdateReady ?? 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.', body:
_l10n?.notifUpdateReadyBody(version) ??
'SpotiFLAC v$version downloaded. Tap to install.',
details: details, details: details,
); );
} }
@@ -583,8 +616,10 @@ class NotificationService {
await _showSafely( await _showSafely(
id: updateDownloadId, id: updateDownloadId,
title: 'Update Failed', title: _l10n?.notifUpdateFailed ?? 'Update Failed',
body: 'Could not download update. Try again later.', body:
_l10n?.notifUpdateFailedBody ??
'Could not download update. Try again later.',
details: details, details: details,
); );
} }
-16
View File
@@ -20,12 +20,6 @@ class PlatformBridge {
static bool get supportsExtensionSystem => static bool get supportsExtensionSystem =>
Platform.isAndroid || Platform.isIOS; Platform.isAndroid || Platform.isIOS;
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
_log.d('parseSpotifyUrl: $url');
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> checkAvailability( static Future<Map<String, dynamic>> checkAvailability(
String spotifyId, String spotifyId,
String isrc, String isrc,
@@ -654,16 +648,6 @@ 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>> getSpotifyMetadataWithFallback(
String url,
) async {
final result = await _channel.invokeMethod(
'getSpotifyMetadataWithFallback',
{'url': url},
);
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<List<Map<String, dynamic>>> getGoLogs() async { static Future<List<Map<String, dynamic>>> getGoLogs() async {
final result = await _channel.invokeMethod('getLogs'); final result = await _channel.invokeMethod('getLogs');
final logs = jsonDecode(result as String) as List<dynamic>; final logs = jsonDecode(result as String) as List<dynamic>;
+6 -6
View File
@@ -5,25 +5,25 @@ class ShellNavigationService {
GlobalKey<NavigatorState>(); GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> libraryTabNavigatorKey = static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
GlobalKey<NavigatorState>(); GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> storeTabNavigatorKey = static final GlobalKey<NavigatorState> repoTabNavigatorKey =
GlobalKey<NavigatorState>(); GlobalKey<NavigatorState>();
static int _currentTabIndex = 0; static int _currentTabIndex = 0;
static bool _showStoreTab = false; static bool _showRepoTab = false;
static void syncState({ static void syncState({
required int currentTabIndex, required int currentTabIndex,
required bool showStoreTab, required bool showRepoTab,
}) { }) {
_currentTabIndex = currentTabIndex; _currentTabIndex = currentTabIndex;
_showStoreTab = showStoreTab; _showRepoTab = showRepoTab;
} }
static NavigatorState? activeTabNavigator() { static NavigatorState? activeTabNavigator() {
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState; if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState; if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
if (_showStoreTab && _currentTabIndex == 2) { if (_showRepoTab && _currentTabIndex == 2) {
return storeTabNavigatorKey.currentState; return repoTabNavigatorKey.currentState;
} }
return null; return null;
} }
+25
View File
@@ -3,6 +3,9 @@ final RegExp _artistNameSplitPattern = RegExp(
caseSensitive: false, caseSensitive: false,
); );
const artistTagModeJoined = 'joined';
const artistTagModeSplitVorbis = 'split_vorbis';
List<String> splitArtistNames(String rawArtists) { List<String> splitArtistNames(String rawArtists) {
final raw = rawArtists.trim(); final raw = rawArtists.trim();
if (raw.isEmpty) return const []; if (raw.isEmpty) return const [];
@@ -13,3 +16,25 @@ List<String> splitArtistNames(String rawArtists) {
.where((part) => part.isNotEmpty) .where((part) => part.isNotEmpty)
.toList(growable: false); .toList(growable: false);
} }
bool shouldSplitVorbisArtistTags(String mode) {
return mode == artistTagModeSplitVorbis;
}
List<String> splitArtistTagValues(String rawArtists) {
final seen = <String>{};
final values = <String>[];
for (final part in splitArtistNames(rawArtists)) {
final key = part.toLowerCase();
if (seen.add(key)) {
values.add(part);
}
}
if (values.isNotEmpty) {
return values;
}
final trimmed = rawArtists.trim();
return trimmed.isEmpty ? const [] : <String>[trimmed];
}
+7 -7
View File
@@ -89,6 +89,10 @@ class LogEntry {
return '$h:$m:$s.$ms'; return '$h:$m:$s.$ms';
} }
String get previewMessage => _truncateLogText(message);
String? get previewError => error == null ? null : _truncateLogText(error!);
@override @override
String toString() { String toString() {
final errorPart = error != null ? ' | $error' : ''; final errorPart = error != null ? ' | $error' : '';
@@ -128,11 +132,9 @@ class LogBuffer extends ChangeNotifier {
return; return;
} }
final sanitizedMessage = _truncateLogText( final sanitizedMessage = _redactSensitiveText(entry.message);
_redactSensitiveText(entry.message),
);
final sanitizedError = entry.error != null final sanitizedError = entry.error != null
? _truncateLogText(_redactSensitiveText(entry.error!)) ? _redactSensitiveText(entry.error!)
: null; : null;
final sanitizedEntry = final sanitizedEntry =
(sanitizedMessage == entry.message && sanitizedError == entry.error) (sanitizedMessage == entry.message && sanitizedError == entry.error)
@@ -381,9 +383,7 @@ class BufferedOutput extends LogOutput {
} }
final level = _levelToString(event.level); final level = _levelToString(event.level);
final message = _truncateLogText( final message = _redactSensitiveText(event.lines.join('\n'));
_redactSensitiveText(event.lines.join('\n')),
);
LogBuffer().add( LogBuffer().add(
LogEntry( LogEntry(
+193
View File
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
/// Progress state communicated from caller to dialog via [ValueNotifier].
class _BatchProgress {
final int current;
final String? detail;
const _BatchProgress({this.current = 0, this.detail});
}
/// A reusable progress dialog for batch operations like conversion and
/// re-enrich. Follows the same visual style as [_FetchingProgressDialog] in
/// artist_screen.dart.
///
/// Uses a static [ValueNotifier] so callers do not need the dialog's
/// [BuildContext] to push updates unlike `findAncestorStateOfType` which
/// fails because the dialog lives in a separate navigator route.
///
/// Usage:
/// ```dart
/// var cancelled = false;
/// BatchProgressDialog.show(
/// context: context,
/// title: 'Converting...',
/// total: items.length,
/// icon: Icons.transform,
/// onCancel: () {
/// cancelled = true;
/// BatchProgressDialog.dismiss(context);
/// },
/// );
///
/// for (int i = 0; i < items.length; i++) {
/// if (cancelled) break;
/// BatchProgressDialog.update(current: i + 1, detail: items[i].name);
/// await doWork(items[i]);
/// }
///
/// BatchProgressDialog.dismiss(context);
/// ```
class BatchProgressDialog extends StatefulWidget {
final String title;
final int total;
final IconData icon;
final VoidCallback onCancel;
final ValueNotifier<_BatchProgress> _progressNotifier;
// ignore: prefer_const_constructors_in_immutables
BatchProgressDialog._({
required this.title,
required this.total,
required this.icon,
required this.onCancel,
required ValueNotifier<_BatchProgress> progressNotifier,
}) : _progressNotifier = progressNotifier;
// Static bookkeeping
static ValueNotifier<_BatchProgress>? _activeNotifier;
/// Show the dialog. Call [update] to push progress, [dismiss] to close.
static void show({
required BuildContext context,
required String title,
required int total,
required VoidCallback onCancel,
IconData icon = Icons.transform,
}) {
_activeNotifier = ValueNotifier(const _BatchProgress());
final notifier = _activeNotifier!;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => BatchProgressDialog._(
title: title,
total: total,
icon: icon,
onCancel: onCancel,
progressNotifier: notifier,
),
);
}
/// Update the progress of the currently visible dialog.
/// No [BuildContext] needed communicates via [ValueNotifier].
static void update({required int current, String? detail}) {
_activeNotifier?.value = _BatchProgress(current: current, detail: detail);
}
/// Dismiss the dialog and clean up.
static void dismiss(BuildContext context) {
_activeNotifier = null;
Navigator.of(context, rootNavigator: true).pop();
}
@override
State<BatchProgressDialog> createState() => _BatchProgressDialogState();
}
class _BatchProgressDialogState extends State<BatchProgressDialog> {
@override
void initState() {
super.initState();
widget._progressNotifier.addListener(_onChanged);
}
@override
void dispose() {
widget._progressNotifier.removeListener(_onChanged);
super.dispose();
}
void _onChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final current = widget._progressNotifier.value.current;
final detail = widget._progressNotifier.value.detail;
final progress = widget.total > 0 ? current / widget.total : 0.0;
return AlertDialog(
backgroundColor: colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
SizedBox(
width: 64,
height: 64,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress > 0 ? progress : null,
strokeWidth: 4,
backgroundColor: colorScheme.surfaceContainerHighest,
),
Icon(widget.icon, color: colorScheme.primary, size: 24),
],
),
),
const SizedBox(height: 20),
Text(
widget.title,
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'$current / ${widget.total}',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (detail != null && detail.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
detail,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress > 0 ? progress : null,
backgroundColor: colorScheme.surfaceContainerHighest,
minHeight: 6,
),
),
],
),
actions: [
TextButton(
onPressed: widget.onCancel,
child: Text(context.l10n.dialogCancel),
),
],
);
}
}
+3 -7
View File
@@ -161,13 +161,9 @@ class _DownloadServicePickerState extends ConsumerState<DownloadServicePicker> {
return ext.qualityOptions; return ext.qualityOptions;
} }
return [ // Extensions without quality options use Tidal's options as default
const QualityOption( // since the download will fall back to built-in providers anyway.
id: 'DEFAULT', return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
label: 'Default Quality',
description: 'Best available',
),
];
} }
@override @override
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none" publish_to: "none"
version: 4.1.1+118 version: 4.1.3+120
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0