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)
}
}
return START_STICKY
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
@@ -40,7 +40,8 @@ class MainActivity: FlutterFragmentActivity() {
"com.zarz.spotiflac/download_progress_stream"
private val LIBRARY_SCAN_PROGRESS_STREAM_CHANNEL =
"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 var pendingSafTreeResult: MethodChannel.Result? = null
private val safScanLock = Any()
@@ -55,6 +56,8 @@ class MainActivity: FlutterFragmentActivity() {
private var flutterBackCallback: OnBackPressedCallback? = null
@Volatile private var safScanCancel = 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(
ActivityResultContracts.StartActivityForResult()
) { activityResult ->
@@ -374,6 +377,8 @@ class MainActivity: FlutterFragmentActivity() {
synchronized(safScanLock) {
safScanProgress = SafScanProgress()
}
// Allow re-probing /proc/self/fd readability on every new scan session.
procSelfFdReadable = null
}
private fun updateSafScanProgress(block: (SafScanProgress) -> Unit) {
@@ -454,7 +459,7 @@ class MainActivity: FlutterFragmentActivity() {
"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
lastLibraryScanProgressPayload = null
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) {
try {
val payload = withContext(Dispatchers.IO) {
@@ -486,7 +503,7 @@ class MainActivity: FlutterFragmentActivity() {
"Library scan progress stream poll failed: ${e.message}",
)
}
delay(STREAM_POLLING_INTERVAL_MS)
delay(LIBRARY_SCAN_PROGRESS_STREAM_POLLING_INTERVAL_MS)
}
}
}
@@ -777,29 +794,59 @@ class MainActivity: FlutterFragmentActivity() {
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(
uri: Uri,
displayNameHint: String? = null,
fallbackExt: String? = null,
coverCacheKey: String = "",
): JSONObject? {
val displayName = buildUriDisplayName(uri, displayNameHint, fallbackExt)
try {
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val directPath = "/proc/self/fd/${pfd.fd}"
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(directPath, displayName)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
if (!obj.has("error")) {
return obj
// Skip /proc/self/fd/ attempt when known to fail (e.g. Samsung SELinux).
if (procSelfFdReadable != false) {
try {
contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
val directPath = "/proc/self/fd/${pfd.fd}"
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
directPath,
displayName,
coverCacheKey,
)
if (metadataJson.isNotBlank()) {
val obj = JSONObject(metadataJson)
val filenameFallback = obj.optBoolean("metadataFromFilename", false)
if (!obj.has("error") && !filenameFallback) {
procSelfFdReadable = true
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) {
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) {
android.util.Log.d(
"SpotiFLAC",
"Direct SAF metadata read fallback for $uri: ${e.message}",
)
}
val tempPath = try {
@@ -813,7 +860,11 @@ class MainActivity: FlutterFragmentActivity() {
} ?: return null
try {
val metadataJson = Gobackend.readAudioMetadataWithHintJSON(tempPath, displayName)
val metadataJson = Gobackend.readAudioMetadataWithHintAndCoverCacheKeyJSON(
tempPath,
displayName,
coverCacheKey,
)
if (metadataJson.isBlank()) return null
val obj = JSONObject(metadataJson)
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 audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
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)
if (tempAudioPath == null) {
@@ -1208,11 +1264,12 @@ class MainActivity: FlutterFragmentActivity() {
val cueLastModified = try { cueDoc.lastModified() } catch (_: Exception) { 0L }
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
tempCuePath,
tempDir,
cueDoc.uri.toString(),
cueLastModified
cueLastModified,
coverCacheKey,
)
val cueArray = JSONArray(cueResultsJson)
@@ -1264,13 +1321,19 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
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) {
errors++
} else {
try {
val lastModified = try { doc.lastModified() } catch (_: Exception) { 0L }
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", lastModified)
@@ -1538,6 +1601,11 @@ class MainActivity: FlutterFragmentActivity() {
val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" }
val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT)
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)
if (tempAudioPath == null) {
@@ -1554,11 +1622,12 @@ class MainActivity: FlutterFragmentActivity() {
tempAudioPath = renamedAudio.absolutePath
}
val cueResultsJson = Gobackend.scanCueSheetForLibrary(
val cueResultsJson = Gobackend.scanCueSheetForLibraryWithCoverCacheKey(
tempCuePath,
tempDir,
cueDoc.uri.toString(),
cueLastModified
cueLastModified,
coverCacheKey,
)
val cueArray = JSONArray(cueResultsJson)
@@ -1655,13 +1724,19 @@ class MainActivity: FlutterFragmentActivity() {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
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) {
errors++
} else {
try {
val safeLastModified = try { doc.lastModified() } catch (_: Exception) { lastModified }
val stableUri = doc.uri.toString()
metadataObj.put("id", buildStableLibraryId(stableUri))
metadataObj.put("filePath", stableUri)
metadataObj.put("fileModTime", safeLastModified)
@@ -1941,13 +2016,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(null)
}
"parseSpotifyUrl" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.parseSpotifyURL(url)
}
result.success(response)
}
"checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -2365,11 +2433,13 @@ class MainActivity: FlutterFragmentActivity() {
return@withContext obj.toString()
// Note: temp file NOT deleted here - Dart will clean up after FFmpeg + writeTempToSaf
}
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
}
raw
// FLAC: Go wrote directly to temp, copy back now
if (!writeUriFromPath(uri, tempPath)) {
try { File(tempPath).delete() } catch (_: Exception) {}
return@withContext """{"error":"Failed to write metadata back to SAF file"}"""
}
try { File(tempPath).delete() } catch (_: Exception) {}
raw
} catch (e: Exception) {
try { File(tempPath).delete() } catch (_: Exception) {}
throw e
@@ -2711,13 +2781,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getSpotifyMetadataWithFallback" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadataWithDeezerFallback(url)
}
result.success(response)
}
"checkAvailabilityFromDeezerID" -> {
val deezerTrackId = call.argument<String>("deezer_track_id") ?: ""
val response = withContext(Dispatchers.IO) {
+26 -3
View File
@@ -980,6 +980,8 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
}
reader := bytes.NewReader(data)
artistValues := make([]string, 0, 1)
albumArtistValues := make([]string, 0, 1)
// Read vendor string length
var vendorLen uint32
@@ -1034,9 +1036,9 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
artistValues = append(artistValues, value)
case "ALBUMARTIST", "ALBUM_ARTIST", "ALBUM ARTIST":
metadata.AlbumArtist = value
albumArtistValues = append(albumArtistValues, value)
case "ALBUM":
metadata.Album = value
case "DATE", "YEAR":
@@ -1066,6 +1068,13 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
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) {
@@ -1620,14 +1629,28 @@ func extractAnyCoverArtWithHint(filePath, displayNameHint string) ([]byte, strin
}
func SaveCoverToCache(filePath, cacheDir string) (string, error) {
return SaveCoverToCacheWithHint(filePath, "", cacheDir)
return SaveCoverToCacheWithHintAndKey(filePath, "", cacheDir, "")
}
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
if stat, err := os.Stat(filePath); err == nil {
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)
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)
}
}
+33 -9
View File
@@ -26,11 +26,11 @@ type CueSheet struct {
// CueTrack represents a single track in a cue sheet
type CueTrack struct {
Number int `json:"number"`
Title string `json:"title"`
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
Number int `json:"number"`
Title string `json:"title"`
Performer string `json:"performer"`
ISRC string `json:"isrc,omitempty"`
Composer string `json:"composer,omitempty"`
StartTime float64 `json:"start_time"` // INDEX 01 in seconds
PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present)
}
@@ -422,7 +422,7 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
if err != nil {
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
@@ -433,6 +433,17 @@ func ScanCueFileForLibrary(cuePath string, scanTime string) ([]LibraryScanResult
// - 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)
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)
if err != nil {
return nil, err
@@ -441,7 +452,15 @@ func ScanCueFileForLibraryExt(cuePath, audioDir, virtualPathPrefix string, fileM
if err != nil {
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) {
@@ -459,7 +478,7 @@ func resolveCueAudioPathForLibrary(cuePath string, sheet *CueSheet, audioDir str
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 {
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
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" {
cp, err := SaveCoverToCache(audioPath, coverCacheDir)
cp, err := SaveCoverToCacheWithHintAndKey(
audioPath,
"",
coverCacheDir,
coverCacheKey,
)
if err == nil && cp != "" {
coverPath = cp
}
+22 -19
View File
@@ -204,7 +204,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
}
if 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)
// 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 != "" {
resolvedID := extractDeezerIDFromURL(availability.DeezerURL)
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)
// Fall through to ISRC search instead of using wrong track.
} else {
@@ -240,7 +240,7 @@ func resolveDeezerTrackURL(req DownloadRequest) (string, error) {
if err == nil && track != nil {
resolvedID := songLinkExtractDeezerTrackID(track)
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)
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")
}
func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
func verifyDeezerTrack(req DownloadRequest, deezerID string, skipNameVerification bool) error {
ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout)
defer cancel()
trackResp, err := GetDeezerClient().GetTrack(ctx, deezerID)
@@ -260,9 +260,11 @@ func verifyDeezerTrack(req DownloadRequest, deezerID string) error {
return nil // Can't verify — don't block the download.
}
resolved := resolvedTrackInfo{
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
Duration: trackResp.Track.DurationMS / 1000,
Title: trackResp.Track.Name,
ArtistName: trackResp.Track.Artists,
ISRC: trackResp.Track.ISRC,
Duration: trackResp.Track.DurationMS / 1000,
SkipNameVerification: skipNameVerification,
}
if !trackMatchesRequest(req, resolved, "Deezer") {
return fmt.Errorf("expected '%s - %s', got '%s - %s'",
@@ -522,18 +524,19 @@ func downloadFromDeezer(req DownloadRequest) (DeezerDownloadResult, error) {
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
+377 -188
View File
@@ -13,25 +13,6 @@ import (
"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) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -68,6 +49,7 @@ type DownloadRequest struct {
FilenameFormat string `json:"filename_format"`
Quality string `json:"quality"`
EmbedMetadata bool `json:"embed_metadata"`
ArtistTagMode string `json:"artist_tag_mode,omitempty"`
EmbedLyrics bool `json:"embed_lyrics"`
EmbedMaxQualityCover bool `json:"embed_max_quality_cover"`
TrackNumber int `json:"track_number"`
@@ -135,6 +117,314 @@ type DownloadResult struct {
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(
req DownloadRequest,
album string,
@@ -864,6 +1154,26 @@ func ScanCueSheetForLibrary(cuePath, audioDir, virtualPathPrefix string, fileMod
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.
// For FLAC files, uses native Go FLAC library.
// For MP3/Opus, returns the metadata map so Dart can use FFmpeg.
@@ -888,19 +1198,20 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) {
}
meta := Metadata{
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
Date: fields["date"],
TrackNumber: trackNum,
DiscNumber: discNum,
ISRC: fields["isrc"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
Title: fields["title"],
Artist: fields["artist"],
Album: fields["album"],
AlbumArtist: fields["album_artist"],
ArtistTagMode: fields["artist_tag_mode"],
Date: fields["date"],
TrackNumber: trackNum,
DiscNumber: discNum,
ISRC: fields["isrc"],
Genre: fields["genre"],
Label: fields["label"],
Copyright: fields["copyright"],
Composer: fields["composer"],
Comment: fields["comment"],
}
if err := EmbedMetadata(filePath, meta, coverPath); err != nil {
@@ -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)
}
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) {
client := NewSongLinkClient()
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
// complete metadata from the internet before embedding.
func ReEnrichFile(requestJSON string) (string, error) {
var req struct {
FilePath string `json:"file_path"`
CoverURL string `json:"cover_url"`
MaxQuality bool `json:"max_quality"`
EmbedLyrics bool `json:"embed_lyrics"`
SpotifyID string `json:"spotify_id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
ReleaseDate string `json:"release_date"`
ISRC string `json:"isrc"`
Genre string `json:"genre"`
Label string `json:"label"`
Copyright string `json:"copyright"`
DurationMs int64 `json:"duration_ms"`
SearchOnline bool `json:"search_online"`
}
var req reEnrichRequest
if err := json.Unmarshal([]byte(requestJSON), &req); err != nil {
return "", fmt.Errorf("failed to parse request: %w", err)
@@ -1842,42 +2068,22 @@ func ReEnrichFile(requestJSON string) (string, error) {
deezerClient := GetDeezerClient()
GoLog("[ReEnrich] Trying metadata providers in configured priority...\n")
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)
if searchErr == nil && len(tracks) > 0 {
track := tracks[0]
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s)\n", track.ProviderID, track.Name, track.Artists, track.AlbumName)
if track.SpotifyID != "" {
req.SpotifyID = track.SpotifyID
} else if track.DeezerID != "" {
req.SpotifyID = "deezer:" + track.DeezerID
} else if track.QobuzID != "" {
req.SpotifyID = "qobuz:" + track.QobuzID
} else if track.TidalID != "" {
req.SpotifyID = "tidal:" + track.TidalID
} else {
req.SpotifyID = track.ID
track := selectBestReEnrichTrack(req, tracks)
if track != nil {
GoLog("[ReEnrich] Metadata match (%s): %s - %s (album: %s, date: %s)\n",
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate)
applyReEnrichTrackMetadata(&req, *track)
found = true
}
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
coverURL := track.ResolvedCoverURL()
if coverURL != "" {
req.CoverURL = coverURL
}
req.DurationMs = int64(track.DurationMS)
if track.Genre != "" {
req.Genre = track.Genre
}
if track.Label != "" {
req.Label = track.Label
}
if track.Copyright != "" {
req.Copyright = track.Copyright
}
found = true
} else if searchErr != nil {
GoLog("[ReEnrich] Metadata provider search failed: %v\n", searchErr)
}
@@ -2008,18 +2214,19 @@ func ReEnrichFile(requestJSON string) (string, error) {
if isFlac {
// Native Go FLAC metadata embedding
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Lyrics: lyricsLRC,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: req.ReleaseDate,
TrackNumber: req.TrackNumber,
DiscNumber: req.DiscNumber,
ISRC: req.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Lyrics: lyricsLRC,
}
if len(coverDataBytes) > 0 {
@@ -2055,36 +2262,14 @@ func ReEnrichFile(requestJSON string) (string, error) {
// Don't cleanup cover temp — Dart needs it for FFmpeg embed
cleanupCover = false
ffmpegMetadata := buildReEnrichFFmpegMetadata(req, lyricsLRC)
result := map[string]interface{}{
"method": "ffmpeg",
"cover_path": coverTempPath,
"lyrics": lyricsLRC,
"enriched_metadata": enrichedMeta,
"metadata": map[string]string{
"TITLE": req.TrackName,
"ARTIST": req.ArtistName,
"ALBUM": req.AlbumName,
"ALBUMARTIST": req.AlbumArtist,
"DATE": req.ReleaseDate,
"ISRC": req.ISRC,
"GENRE": req.Genre,
},
}
if req.TrackNumber > 0 {
result["metadata"].(map[string]string)["TRACKNUMBER"] = fmt.Sprintf("%d", req.TrackNumber)
}
if req.DiscNumber > 0 {
result["metadata"].(map[string]string)["DISCNUMBER"] = fmt.Sprintf("%d", req.DiscNumber)
}
if req.Label != "" {
result["metadata"].(map[string]string)["ORGANIZATION"] = req.Label
}
if req.Copyright != "" {
result["metadata"].(map[string]string)["COPYRIGHT"] = req.Copyright
}
if lyricsLRC != "" {
result["metadata"].(map[string]string)["LYRICS"] = lyricsLRC
result["metadata"].(map[string]string)["UNSYNCEDLYRICS"] = lyricsLRC
"metadata": ffmpegMetadata,
}
jsonBytes, _ := json.Marshal(result)
@@ -3328,3 +3513,7 @@ func ReadAudioMetadataJSON(filePath string) (string, error) {
func ReadAudioMetadataWithHintJSON(filePath, displayName string) (string, error) {
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)
}
}
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)
}
}
}
+62 -34
View File
@@ -13,25 +13,26 @@ import (
)
type LibraryScanResult struct {
ID string `json:"id"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
AlbumArtist string `json:"albumArtist,omitempty"`
FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"`
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
Duration int `json:"duration,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"`
ID string `json:"id"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
AlbumArtist string `json:"albumArtist,omitempty"`
FilePath string `json:"filePath"`
CoverPath string `json:"coverPath,omitempty"`
ScannedAt string `json:"scannedAt"`
FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds
ISRC string `json:"isrc,omitempty"`
TrackNumber int `json:"trackNumber,omitempty"`
DiscNumber int `json:"discNumber,omitempty"`
Duration int `json:"duration,omitempty"`
ReleaseDate string `json:"releaseDate,omitempty"`
BitDepth int `json:"bitDepth,omitempty"`
SampleRate int `json:"sampleRate,omitempty"`
Bitrate int `json:"bitrate,omitempty"` // kbps, for lossy formats (MP3, Opus, Vorbis)
Genre string `json:"genre,omitempty"`
Format string `json:"format,omitempty"`
MetadataFromFilename bool `json:"metadataFromFilename,omitempty"`
}
type LibraryScanProgress struct {
@@ -219,6 +220,7 @@ func ScanLibraryFolder(folderPath string) (string, error) {
cueInfo.audioPath,
"",
fileInfo.modTime,
"",
scanTime,
)
} else {
@@ -269,10 +271,14 @@ func scanAudioFile(filePath, scanTime string) (*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) {
return scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, "", scanTime, knownModTime)
}
func scanAudioFileWithKnownModTimeAndDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey, scanTime string, knownModTime int64) (*LibraryScanResult, error) {
ext := resolveLibraryAudioExt(filePath, displayNameHint)
result := &LibraryScanResult{
@@ -292,7 +298,12 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
coverCacheDir := libraryCoverCacheDir
libraryCoverCacheMu.RUnlock()
if coverCacheDir != "" {
coverPath, err := SaveCoverToCacheWithHint(filePath, displayNameHint, coverCacheDir)
coverPath, err := SaveCoverToCacheWithHintAndKey(
filePath,
displayNameHint,
coverCacheDir,
coverCacheKey,
)
if err == nil && coverPath != "" {
result.CoverPath = coverPath
}
@@ -300,11 +311,11 @@ func scanAudioFileWithKnownModTimeAndDisplayName(filePath, displayNameHint, scan
switch ext {
case ".flac":
return scanFLACFile(filePath, result)
return scanFLACFile(filePath, result, displayNameHint)
case ".m4a":
return scanM4AFile(filePath, result)
return scanM4AFile(filePath, result, displayNameHint)
case ".mp3":
return scanMP3File(filePath, result)
return scanMP3File(filePath, result, displayNameHint)
case ".opus", ".ogg":
return scanOggFile(filePath, result, displayNameHint)
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)
if err != nil {
return scanFromFilename(filePath, "", result)
return scanFromFilename(filePath, displayNameHint, result)
}
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
}
func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult, error) {
func scanM4AFile(filePath string, result *LibraryScanResult, displayNameHint string) (*LibraryScanResult, error) {
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.ArtistName = metadata.Artist
result.AlbumName = metadata.Album
@@ -393,15 +409,15 @@ func scanM4AFile(filePath string, result *LibraryScanResult) (*LibraryScanResult
result.SampleRate = quality.SampleRate
}
applyDefaultLibraryMetadata(filePath, "", result)
applyDefaultLibraryMetadata(filePath, displayNameHint, result)
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)
if err != nil {
GoLog("[LibraryScan] ID3 read error for %s: %v\n", filePath, err)
return scanFromFilename(filePath, "", result)
return scanFromFilename(filePath, displayNameHint, result)
}
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
}
@@ -466,6 +482,7 @@ func scanOggFile(filePath string, result *LibraryScanResult, displayNameHint str
}
func scanFromFilename(filePath, displayNameHint string, result *LibraryScanResult) (*LibraryScanResult, error) {
result.MetadataFromFilename = true
nameSource := libraryDisplayNameOrPath(filePath, displayNameHint)
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) {
return ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, "")
}
func ReadAudioMetadataWithDisplayNameAndCoverCacheKey(filePath, displayNameHint, coverCacheKey string) (string, error) {
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 {
return "", err
}
@@ -746,6 +773,7 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi
cueInfo.audioPath,
"",
f.modTime,
"",
scanTime,
)
} 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 (
defaultLogBufferSize = 500
maxLogMessageLength = 500
)
var (
@@ -58,14 +57,6 @@ func GetLogBuffer() *LogBuffer {
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) {
lb.mu.Lock()
defer lb.mu.Unlock()
@@ -87,7 +78,6 @@ func (lb *LogBuffer) Add(level, tag, message string) {
}
message = sanitizeSensitiveLogText(message)
message = truncateLogMessage(message)
entry := LogEntry{
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("[ar:%s]\n", artistName))
builder.WriteString("[by:SpotiFLAC-Mobile]\n")
builder.WriteString("[by:Implemented by SpotiFLAC-Mobile using Paxsenix API]\n")
builder.WriteString("\n")
if lyrics.SyncType == "LINE_SYNCED" {
+128 -25
View File
@@ -11,6 +11,7 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@@ -19,6 +20,10 @@ import (
"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 {
// Prefer magic-byte detection over file extension.
// Some providers return non-JPEG data behind .jpg URLs.
@@ -96,22 +101,23 @@ func buildPictureBlock(coverPath string, coverData []byte) (flac.MetaDataBlock,
}
type Metadata struct {
Title string
Artist string
Album string
AlbumArtist string
Date string
TrackNumber int
TotalTracks int
DiscNumber int
ISRC string
Description string
Lyrics string
Genre string
Label string
Copyright string
Composer string
Comment string
Title string
Artist string
Album string
AlbumArtist string
ArtistTagMode string
Date string
TrackNumber int
TotalTracks int
DiscNumber int
ISRC string
Description string
Lyrics string
Genre string
Label string
Copyright string
Composer string
Comment string
}
func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
@@ -139,9 +145,14 @@ func EmbedMetadata(filePath string, metadata Metadata, coverPath string) error {
}
setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist)
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
setArtistComments(
cmt,
"ALBUMARTIST",
metadata.AlbumArtist,
metadata.ArtistTagMode,
)
setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 {
@@ -248,9 +259,14 @@ func EmbedMetadataWithCoverData(filePath string, metadata Metadata, coverData []
}
setComment(cmt, "TITLE", metadata.Title)
setComment(cmt, "ARTIST", metadata.Artist)
setArtistComments(cmt, "ARTIST", metadata.Artist, metadata.ArtistTagMode)
setComment(cmt, "ALBUM", metadata.Album)
setComment(cmt, "ALBUMARTIST", metadata.AlbumArtist)
setArtistComments(
cmt,
"ALBUMARTIST",
metadata.AlbumArtist,
metadata.ArtistTagMode,
)
setComment(cmt, "DATE", metadata.Date)
if metadata.TrackNumber > 0 {
@@ -339,9 +355,9 @@ func ReadMetadata(filePath string) (*Metadata, error) {
}
metadata.Title = getComment(cmt, "TITLE")
metadata.Artist = getComment(cmt, "ARTIST")
metadata.Artist = getJoinedComment(cmt, "ARTIST")
metadata.Album = getComment(cmt, "ALBUM")
metadata.AlbumArtist = getComment(cmt, "ALBUMARTIST")
metadata.AlbumArtist = getJoinedComment(cmt, "ALBUMARTIST")
metadata.Date = getComment(cmt, "DATE")
metadata.ISRC = getComment(cmt, "ISRC")
metadata.Description = getComment(cmt, "DESCRIPTION")
@@ -394,6 +410,28 @@ func setComment(cmt *flacvorbis.MetaDataBlockVorbisComment, key, value string) {
if value == "" {
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)
for i := len(cmt.Comments) - 1; i >= 0; 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 {
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) + "="
values := make([]string, 0, 1)
for _, comment := range cmt.Comments {
if len(comment) > len(key) {
commentUpper := strings.ToUpper(comment[:len(key)+1])
if commentUpper == keyUpper {
return comment[len(key)+1:]
values = append(values, comment[len(key)+1:])
}
}
}
return ""
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 ""
}
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 {
+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()
}
+300 -34
View File
@@ -13,6 +13,7 @@ import (
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
@@ -1597,21 +1598,27 @@ func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistNam
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 {
return false
}
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.ArtistName, track.Performer.Name)
return false
}
exactISRCMatch := req.ISRC != "" &&
track.ISRC != "" &&
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(track.ISRC))
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.TrackName, track.Title)
return false
if !exactISRCMatch && !skipNameVerification {
if req.ArtistName != "" && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
GoLog("[%s] Artist mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.ArtistName, track.Performer.Name)
return false
}
if req.TrackName != "" && !qobuzTitlesMatch(req.TrackName, track.Title) {
GoLog("[%s] Title mismatch from %s: expected '%s', got '%s'. Rejecting.\n",
logPrefix, source, req.TrackName, track.Title)
return false
}
}
expectedDurationSec := req.DurationMS / 1000
@@ -1644,7 +1651,8 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
defer resp.Body.Close()
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 {
@@ -1658,6 +1666,234 @@ func (q *QobuzDownloader) searchQobuzTracksViaAPI(query string, limit int) ([]Qo
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 {
matches := qobuzStoreTrackIDRegex.FindAllSubmatch(body, -1)
if len(matches) == 0 {
@@ -1735,9 +1971,18 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
if len(apiTracks) > 0 {
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 {
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)
@@ -1746,10 +1991,21 @@ func (q *QobuzDownloader) searchQobuzTracksWithFallback(query string, limit int)
return storeTracks, nil
}
if apiErr != nil && storeErr != nil {
return nil, fmt.Errorf("api search failed (%v); store fallback failed (%v)", apiErr, storeErr)
if apiErr != nil && albumErr != nil && storeErr != nil {
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 albumErr != nil {
return nil, albumErr
}
return nil, storeErr
}
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)
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)
} else {
track = nil
@@ -2142,7 +2398,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if err != nil {
GoLog("[%s] Cache hit but GetTrackByID failed: %v\n", logPrefix, err)
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
}
}
@@ -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)
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)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
@@ -2179,7 +2435,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil && req.ISRC != "" {
GoLog("[%s] Trying ISRC search: %s\n", logPrefix, req.ISRC)
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
}
}
@@ -2188,7 +2444,7 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade
if track == nil {
GoLog("[%s] Trying metadata search: '%s' by '%s'\n", logPrefix, req.TrackName, req.ArtistName)
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
}
}
@@ -2253,7 +2509,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
qobuzQuality = "6"
case "HI_RES":
qobuzQuality = "7"
case "HI_RES_LOSSLESS":
case "HI_RES_LOSSLESS", "", "DEFAULT":
qobuzQuality = "27"
}
GoLog("[Qobuz] Using quality: %s (mapped from %s)\n", qobuzQuality, req.Quality)
@@ -2329,18 +2585,19 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
}
metadata := Metadata{
Title: track.Title,
Artist: track.Performer.Name,
Album: albumName,
AlbumArtist: req.AlbumArtist,
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Title: track.Title,
Artist: req.ArtistName,
Album: albumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: req.DiscNumber,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
@@ -2405,6 +2662,15 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
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{
FilePath: outputPath,
BitDepth: actualBitDepth,
@@ -2416,7 +2682,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber,
ISRC: track.ISRC,
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
CoverURL: resultCoverURL,
LyricsLRC: lyricsLRC,
}, nil
}
+94
View File
@@ -5,6 +5,21 @@ import (
"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) {
tests := []struct {
name string
@@ -276,6 +291,68 @@ func testQobuzTrack(id int64, title, artist string, duration int) *QobuzTrack {
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) {
origGetTrackByID := qobuzGetTrackByIDFunc
origSearchISRC := qobuzSearchTrackByISRCWithDurationFunc
@@ -436,3 +513,20 @@ func TestResolveQobuzTrackForRequestUsesPrefixedQobuzIDWithoutSongLink(t *testin
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)
}
}
+23 -16
View File
@@ -829,6 +829,7 @@ func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, a
resolved := resolvedTrackInfo{
Title: strings.TrimSpace(track.Title),
ArtistName: tidalTrackArtistsDisplay(track),
ISRC: strings.TrimSpace(track.ISRC),
Duration: track.Duration,
}
if trackMatchesRequest(req, resolved, "Tidal search") {
@@ -2035,6 +2036,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
expectedDurationSec := req.DurationMS / 1000
var trackID int64
var gotTidalID bool
var resolvedViaSongLink bool
if 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
GoLog("[%s] Got Tidal ID %d directly from SongLink\n", logPrefix, trackID)
gotTidalID = true
resolvedViaSongLink = true
return
}
}
@@ -2103,6 +2106,7 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
if idErr == nil && trackID > 0 {
GoLog("[%s] Got Tidal ID %d from URL parsing\n", logPrefix, trackID)
gotTidalID = true
resolvedViaSongLink = true
}
}
}
@@ -2157,9 +2161,11 @@ func resolveTidalTrackForRequest(req DownloadRequest, downloader *TidalDownloade
providerArtist = actualTrack.Artists[0].Name
}
resolved := resolvedTrackInfo{
Title: actualTrack.Title,
ArtistName: providerArtist,
Duration: actualTrack.Duration,
Title: actualTrack.Title,
ArtistName: providerArtist,
ISRC: strings.TrimSpace(actualTrack.ISRC),
Duration: actualTrack.Duration,
SkipNameVerification: resolvedViaSongLink,
}
if !trackMatchesRequest(req, resolved, logPrefix) {
// Invalidate the cached ID so future requests don't reuse it.
@@ -2206,7 +2212,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
quality := req.Quality
if quality == "" {
if quality == "" || quality == "DEFAULT" {
quality = "LOSSLESS"
}
@@ -2348,18 +2354,19 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
Title: req.TrackName,
Artist: req.ArtistName,
Album: req.AlbumName,
AlbumArtist: req.AlbumArtist,
ArtistTagMode: req.ArtistTagMode,
Date: releaseDate,
TrackNumber: actualTrackNumber,
TotalTracks: req.TotalTracks,
DiscNumber: actualDiscNumber,
ISRC: track.ISRC,
Genre: req.Genre,
Label: req.Label,
Copyright: req.Copyright,
}
var coverData []byte
+38 -15
View File
@@ -7,6 +7,21 @@ import (
"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
// "Doctor / Cops" and "Doctor _ Cops" can still match.
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):
continue
case unicode.IsLetter(r), unicode.IsNumber(r):
b.WriteRune(r)
writeNormalizedArtistRune(&b, r)
case unicode.IsSpace(r):
b.WriteByte(' ')
case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+':
@@ -101,26 +116,34 @@ func normalizeSymbolOnlyTitle(title string) string {
// resolvedTrackInfo holds the metadata fetched from a provider for verification.
type resolvedTrackInfo struct {
Title string
ArtistName string
Duration int
Title string
ArtistName string
ISRC string
Duration int
SkipNameVerification bool
}
// trackMatchesRequest checks whether a resolved track from a provider matches
// the original download request. Returns true if the track is a plausible match.
func trackMatchesRequest(req DownloadRequest, resolved resolvedTrackInfo, logPrefix string) bool {
if req.ArtistName != "" && resolved.ArtistName != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) {
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
exactISRCMatch := req.ISRC != "" &&
resolved.ISRC != "" &&
strings.EqualFold(strings.TrimSpace(req.ISRC), strings.TrimSpace(resolved.ISRC))
if req.TrackName != "" && resolved.Title != "" &&
!titlesMatch(req.TrackName, resolved.Title) {
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
logPrefix, req.TrackName, resolved.Title)
return false
if !exactISRCMatch && !resolved.SkipNameVerification {
if req.ArtistName != "" && resolved.ArtistName != "" &&
!artistsMatch(req.ArtistName, resolved.ArtistName) {
GoLog("[%s] Verification failed: artist mismatch — expected '%s', got '%s'\n",
logPrefix, req.ArtistName, resolved.ArtistName)
return false
}
if req.TrackName != "" && resolved.Title != "" &&
!titlesMatch(req.TrackName, resolved.Title) {
GoLog("[%s] Verification failed: title mismatch — expected '%s', got '%s'\n",
logPrefix, req.TrackName, resolved.Title)
return false
}
}
expectedDurationSec := req.DurationMS / 1000
+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) {
if !titlesMatch("Doctor / Cops", "Doctor _ Cops") {
t.Fatal("expected tidal titlesMatch to accept / vs _ variant")
-14
View File
@@ -153,13 +153,6 @@ import Gobackend // Import Go framework
var error: NSError?
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":
let args = call.arguments as! [String: Any]
let spotifyId = args["spotify_id"] as! String
@@ -469,13 +462,6 @@ import Gobackend // Import Go framework
if let error = error { throw error }
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":
let args = call.arguments as! [String: Any]
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
/// Update version here only - all other files will reference this
class AppInfo {
static const String version = '4.1.1';
static const String buildNumber = '118';
static const String version = '4.1.3';
static const String buildNumber = '120';
static const String fullVersion = '$version+$buildNumber';
/// 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
///
/// In en, this message translates to:
/// **'Store'**
/// **'Repo'**
String get navStore;
/// Home screen title
@@ -163,7 +163,7 @@ abstract class AppLocalizations {
/// Subtitle shown below search box
///
/// 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;
/// Info text about supported URL types
@@ -400,6 +400,42 @@ abstract class AppLocalizations {
/// **'Download highest resolution cover art'**
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
///
/// In en, this message translates to:
@@ -427,13 +463,13 @@ abstract class AppLocalizations {
/// Show/hide store tab
///
/// In en, this message translates to:
/// **'Extension Store'**
/// **'Extension Repo'**
String get optionsExtensionStore;
/// Subtitle for extension store toggle
///
/// In en, this message translates to:
/// **'Show Store tab in navigation'**
/// **'Show Repo tab in navigation'**
String get optionsExtensionStoreSubtitle;
/// Auto update check toggle
@@ -565,7 +601,7 @@ abstract class AppLocalizations {
/// Store screen title
///
/// In en, this message translates to:
/// **'Extension Store'**
/// **'Extension Repo'**
String get storeTitle;
/// Store search placeholder
@@ -2365,7 +2401,7 @@ abstract class AppLocalizations {
/// Error heading when the store cannot be loaded
///
/// In en, this message translates to:
/// **'Failed to load store'**
/// **'Failed to load repository'**
String get storeLoadError;
/// Message when store has no extensions
@@ -3334,6 +3370,12 @@ abstract class AppLocalizations {
/// **'{count, plural, =1{track} other{tracks}}'**
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
///
/// In en, this message translates to:
@@ -3352,6 +3394,12 @@ abstract class AppLocalizations {
/// **'Scanning...'**
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
///
/// In en, this message translates to:
@@ -3478,6 +3526,42 @@ abstract class AppLocalizations {
/// **'Format'**
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
///
/// In en, this message translates to:
@@ -3496,6 +3580,30 @@ abstract class AppLocalizations {
/// **'Oldest'**
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
///
/// In en, this message translates to:
@@ -3613,7 +3721,7 @@ abstract class AppLocalizations {
/// Tutorial extensions tip 1
///
/// 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;
/// Tutorial extensions tip 2
@@ -5300,6 +5408,298 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Samples'**
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
+254 -1
View File
@@ -158,6 +158,27 @@ class AppLocalizationsDe extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle =>
'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
String get optionsConcurrentDownloads => 'Parallele Downloads';
@@ -1281,7 +1302,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1851,6 +1872,17 @@ class AppLocalizationsDe extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Zuletzt gescannt: $time';
@@ -1862,6 +1894,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get libraryScanning => 'Scannen...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% von $total Dateien';
@@ -1930,6 +1965,24 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sortieren';
@@ -1939,6 +1992,18 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String get timeJustNow => 'Gerade eben';
@@ -3124,4 +3189,192 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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';
@override
String get navStore => 'Store';
String get navStore => 'Repo';
@override
String get homeTitle => 'Home';
@override
String get homeSubtitle => 'Paste a Spotify link or search by name';
String get homeSubtitle => 'Paste a supported URL or search by name';
@override
String get homeSupports => 'Supports: Track, Album, Playlist, Artist URLs';
@@ -154,6 +154,27 @@ class AppLocalizationsEn extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle =>
'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
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -170,10 +191,10 @@ class AppLocalizationsEn extends AppLocalizations {
'Parallel downloads may trigger rate limiting';
@override
String get optionsExtensionStore => 'Extension Store';
String get optionsExtensionStore => 'Extension Repo';
@override
String get optionsExtensionStoreSubtitle => 'Show Store tab in navigation';
String get optionsExtensionStoreSubtitle => 'Show Repo tab in navigation';
@override
String get optionsCheckUpdates => 'Check for Updates';
@@ -250,7 +271,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get extensionsUninstall => 'Uninstall';
@override
String get storeTitle => 'Extension Store';
String get storeTitle => 'Extension Repo';
@override
String get storeSearch => 'Search extensions...';
@@ -1261,7 +1282,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsEn extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -1997,7 +2062,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -3092,4 +3157,192 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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 =>
'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
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsEs extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -1997,7 +2062,7 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -3092,6 +3157,194 @@ class AppLocalizationsEs extends AppLocalizations {
@override
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`).
+254 -1
View File
@@ -156,6 +156,27 @@ class AppLocalizationsFr extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle =>
'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
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1263,7 +1284,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1825,6 +1846,17 @@ class AppLocalizationsFr extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1836,6 +1868,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1904,6 +1939,24 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1913,6 +1966,18 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -3093,4 +3158,192 @@ class AppLocalizationsFr extends AppLocalizations {
@override
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 =>
'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
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsHi extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsHi extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsHi extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsHi extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -3091,4 +3156,192 @@ class AppLocalizationsHi extends AppLocalizations {
@override
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';
@override
String get navStore => 'Toko';
String get navStore => 'Repo';
@override
String get homeTitle => 'Beranda';
@override
String get homeSubtitle => 'Tempel link Spotify atau cari berdasarkan nama';
String get homeSubtitle =>
'Tempel URL yang didukung atau cari berdasarkan nama';
@override
String get homeSupports => 'Mendukung: URL Track, Album, Playlist, Artis';
@@ -157,6 +158,27 @@ class AppLocalizationsId extends AppLocalizations {
String get optionsMaxQualityCoverSubtitle =>
'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
String get optionsConcurrentDownloads => 'Unduhan Bersamaan';
@@ -173,10 +195,10 @@ class AppLocalizationsId extends AppLocalizations {
'Unduhan paralel dapat memicu pembatasan rate';
@override
String get optionsExtensionStore => 'Toko Ekstensi';
String get optionsExtensionStore => 'Repo Ekstensi';
@override
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Toko di navigasi';
String get optionsExtensionStoreSubtitle => 'Tampilkan tab Repo di navigasi';
@override
String get optionsCheckUpdates => 'Periksa Pembaruan';
@@ -252,7 +274,7 @@ class AppLocalizationsId extends AppLocalizations {
String get extensionsUninstall => 'Copot';
@override
String get storeTitle => 'Toko Ekstensi';
String get storeTitle => 'Repo Ekstensi';
@override
String get storeSearch => 'Cari ekstensi...';
@@ -1267,7 +1289,7 @@ class AppLocalizationsId extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Gagal memuat repo';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1832,6 +1854,17 @@ class AppLocalizationsId extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1843,6 +1876,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1911,6 +1947,24 @@ class AppLocalizationsId extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1920,6 +1974,18 @@ class AppLocalizationsId extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -2006,7 +2072,7 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Buka tab Repo untuk menemukan ekstensi yang berguna';
@override
String get tutorialExtensionsTip2 =>
@@ -3101,4 +3167,192 @@ class AppLocalizationsId extends AppLocalizations {
@override
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
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
String get optionsConcurrentDownloads => '同時ダウンロード';
@@ -1255,7 +1276,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1810,6 +1831,17 @@ class AppLocalizationsJa extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return '最終スキャン: $time';
@@ -1821,6 +1853,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get libraryScanning => 'スキャン中...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1889,6 +1924,24 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1898,6 +1951,18 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -3078,4 +3143,192 @@ class AppLocalizationsJa extends AppLocalizations {
@override
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
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
String get optionsConcurrentDownloads => '동시 다운로드';
@@ -1241,7 +1262,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1803,6 +1824,17 @@ class AppLocalizationsKo extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1814,6 +1846,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1882,6 +1917,24 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1891,6 +1944,18 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -3071,4 +3136,192 @@ class AppLocalizationsKo extends AppLocalizations {
@override
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 =>
'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
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsNl extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsNl extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -3091,4 +3156,192 @@ class AppLocalizationsNl extends AppLocalizations {
@override
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 =>
'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
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsPt extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsPt extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -1997,7 +2062,7 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -3092,6 +3157,194 @@ class AppLocalizationsPt extends AppLocalizations {
@override
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`).
+254 -1
View File
@@ -159,6 +159,27 @@ class AppLocalizationsRu extends AppLocalizations {
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
String get optionsConcurrentDownloads => 'Одновременные загрузки';
@@ -1282,7 +1303,7 @@ class AppLocalizationsRu extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1861,6 +1882,17 @@ class AppLocalizationsRu extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Последнее сканирование: $time';
@@ -1872,6 +1904,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get libraryScanning => 'Сканирование...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% из $total файлов';
@@ -1948,6 +1983,24 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Сортировка';
@@ -1957,6 +2010,18 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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
String get timeJustNow => 'Только что';
@@ -3151,4 +3216,192 @@ class AppLocalizationsRu extends AppLocalizations {
@override
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 =>
'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
String get optionsConcurrentDownloads => 'Eş Zamanlı İndirmeler';
@@ -1267,7 +1288,7 @@ class AppLocalizationsTr extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1829,6 +1850,17 @@ class AppLocalizationsTr extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1840,6 +1872,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1908,6 +1943,24 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1917,6 +1970,18 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -3097,4 +3162,192 @@ class AppLocalizationsTr extends AppLocalizations {
@override
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 =>
'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
String get optionsConcurrentDownloads => 'Concurrent Downloads';
@@ -1261,7 +1282,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get storeNewRepoUrlLabel => 'New Repository URL';
@override
String get storeLoadError => 'Failed to load store';
String get storeLoadError => 'Failed to load repository';
@override
String get storeEmptyNoExtensions => 'No extensions available';
@@ -1823,6 +1844,17 @@ class AppLocalizationsZh extends AppLocalizations {
return '$_temp0';
}
@override
String libraryFilesUnit(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'files',
one: 'file',
);
return '$_temp0';
}
@override
String libraryLastScanned(String time) {
return 'Last scanned: $time';
@@ -1834,6 +1866,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get libraryScanning => 'Scanning...';
@override
String get libraryScanFinalizing => 'Finalizing library...';
@override
String libraryScanProgress(String progress, int total) {
return '$progress% of $total files';
@@ -1902,6 +1937,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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
String get libraryFilterSort => 'Sort';
@@ -1911,6 +1964,18 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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
String get timeJustNow => 'Just now';
@@ -1997,7 +2062,7 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get tutorialExtensionsTip1 =>
'Browse the Store tab to discover useful extensions';
'Browse the Repo tab to discover useful extensions';
@override
String get tutorialExtensionsTip2 =>
@@ -3092,6 +3157,194 @@ class AppLocalizationsZh extends AppLocalizations {
@override
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`).
+396 -7
View File
@@ -17,7 +17,7 @@
"@navSettings": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Store",
"navStore": "Repo",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
@@ -25,7 +25,7 @@
"@homeTitle": {
"description": "Home screen title"
},
"homeSubtitle": "Paste a Spotify link or search by name",
"homeSubtitle": "Paste a supported URL or search by name",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
@@ -190,6 +190,30 @@
"@optionsMaxQualityCoverSubtitle": {
"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": {
"description": "Number of parallel downloads"
@@ -211,11 +235,11 @@
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Extension Store",
"optionsExtensionStore": "Extension Repo",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Show Store tab in navigation",
"optionsExtensionStoreSubtitle": "Show Repo tab in navigation",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
@@ -318,7 +342,7 @@
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"storeTitle": "Extension Store",
"storeTitle": "Extension Repo",
"@storeTitle": {
"description": "Store screen title"
},
@@ -1654,7 +1678,7 @@
"@storeNewRepoUrlLabel": {
"description": "Label for the new repository URL field inside the dialog"
},
"storeLoadError": "Failed to load store",
"storeLoadError": "Failed to load repository",
"@storeLoadError": {
"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": {
"description": "Last scan time display",
@@ -2416,6 +2449,10 @@
"@libraryScanning": {
"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": {
"description": "Scan progress display",
@@ -2513,6 +2550,30 @@
"@libraryFilterFormat": {
"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": {
"description": "Filter section - sort order"
@@ -2525,6 +2586,22 @@
"@libraryFilterSortOldest": {
"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": {
"description": "Relative time - less than a minute ago"
@@ -2611,7 +2688,7 @@
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"tutorialExtensionsTip1": "Browse the Repo tab to discover useful extensions",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
@@ -4063,5 +4140,317 @@
"audioAnalysisSamples": "Samples",
"@audioAnalysisSamples": {
"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": {
"description": "Bottom navigation - Settings tab"
},
"navStore": "Toko",
"navStore": "Repo",
"@navStore": {
"description": "Bottom navigation - Extension store tab"
},
@@ -25,7 +25,7 @@
"@homeTitle": {
"description": "Home screen title"
},
"homeSubtitle": "Tempel link Spotify atau cari berdasarkan nama",
"homeSubtitle": "Tempel URL yang didukung atau cari berdasarkan nama",
"@homeSubtitle": {
"description": "Subtitle shown below search box"
},
@@ -211,11 +211,11 @@
"@optionsConcurrentWarning": {
"description": "Warning about rate limits"
},
"optionsExtensionStore": "Toko Ekstensi",
"optionsExtensionStore": "Repo Ekstensi",
"@optionsExtensionStore": {
"description": "Show/hide store tab"
},
"optionsExtensionStoreSubtitle": "Tampilkan tab Toko di navigasi",
"optionsExtensionStoreSubtitle": "Tampilkan tab Repo di navigasi",
"@optionsExtensionStoreSubtitle": {
"description": "Subtitle for extension store toggle"
},
@@ -318,10 +318,14 @@
"@extensionsUninstall": {
"description": "Uninstall extension button"
},
"storeTitle": "Toko Ekstensi",
"storeTitle": "Repo Ekstensi",
"@storeTitle": {
"description": "Store screen title"
},
"storeLoadError": "Gagal memuat repo",
"@storeLoadError": {
"description": "Error heading when the store cannot be loaded"
},
"storeSearch": "Cari ekstensi...",
"@storeSearch": {
"description": "Store search placeholder"
@@ -2459,7 +2463,7 @@
"@tutorialExtensionsDesc": {
"description": "Tutorial extensions page description"
},
"tutorialExtensionsTip1": "Browse the Store tab to discover useful extensions",
"tutorialExtensionsTip1": "Buka tab Repo untuk menemukan ekstensi yang berguna",
"@tutorialExtensionsTip1": {
"description": "Tutorial extensions tip 1"
},
+6
View File
@@ -1,4 +1,5 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
part 'settings.g.dart';
@@ -12,6 +13,8 @@ class AppSettings {
final String downloadTreeUri; // SAF persistable tree URI
final bool autoFallback;
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 maxQualityCover;
final bool isFirstLaunch;
@@ -88,6 +91,7 @@ class AppSettings {
this.downloadTreeUri = '',
this.autoFallback = true,
this.embedMetadata = true,
this.artistTagMode = artistTagModeJoined,
this.embedLyrics = true,
this.maxQualityCover = true,
this.isFirstLaunch = true,
@@ -152,6 +156,7 @@ class AppSettings {
String? downloadTreeUri,
bool? autoFallback,
bool? embedMetadata,
String? artistTagMode,
bool? embedLyrics,
bool? maxQualityCover,
bool? isFirstLaunch,
@@ -210,6 +215,7 @@ class AppSettings {
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
autoFallback: autoFallback ?? this.autoFallback,
embedMetadata: embedMetadata ?? this.embedMetadata,
artistTagMode: artistTagMode ?? this.artistTagMode,
embedLyrics: embedLyrics ?? this.embedLyrics,
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
+2
View File
@@ -15,6 +15,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
downloadTreeUri: json['downloadTreeUri'] as String? ?? '',
autoFallback: json['autoFallback'] as bool? ?? true,
embedMetadata: json['embedMetadata'] as bool? ?? true,
artistTagMode: json['artistTagMode'] as String? ?? 'joined',
embedLyrics: json['embedLyrics'] as bool? ?? true,
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
@@ -93,6 +94,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'downloadTreeUri': instance.downloadTreeUri,
'autoFallback': instance.autoFallback,
'embedMetadata': instance.embedMetadata,
'artistTagMode': instance.artistTagMode,
'embedLyrics': instance.embedLyrics,
'maxQualityCover': instance.maxQualityCover,
'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 status = itemProgress['status'] as String? ?? 'downloading';
if (status == 'finalizing' && bytesTotal > 0) {
if (status == 'finalizing') {
progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing,
progress: 1.0,
@@ -2996,6 +2996,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? coverPath
: null,
metadata: metadata,
artistTagMode: settings.artistTagMode,
);
if (result != null) {
@@ -3328,6 +3329,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? coverPath
: null,
metadata: metadata,
artistTagMode: settings.artistTagMode,
);
if (result != null) {
@@ -3802,6 +3804,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
var quality = item.qualityOverride ?? state.audioQuality;
if (quality == 'DEFAULT') quality = state.audioQuality;
final isSafMode = _isSafMode(settings);
final relativeOutputDir = isSafMode
? await _buildRelativeOutputDir(
@@ -4214,6 +4217,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
filenameFormat: state.filenameFormat,
quality: quality,
embedMetadata: metadataEmbeddingEnabled,
artistTagMode: settings.artistTagMode,
embedLyrics: metadataEmbeddingEnabled && settings.embedLyrics,
embedMaxQualityCover:
metadataEmbeddingEnabled && settings.maxQualityCover,
@@ -4358,7 +4362,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_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)) {
final currentFilePath = filePath;
@@ -4503,7 +4507,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.95,
);
@@ -4524,7 +4528,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.99,
);
@@ -4608,7 +4612,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.95,
);
flacPath = await FFmpegService.convertM4aToFlac(tempPath);
@@ -4684,7 +4688,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.95,
);
@@ -4711,7 +4715,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Embedding metadata to $format...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.99,
);
@@ -4765,7 +4769,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} else {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(
@@ -4849,7 +4853,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.99,
);
@@ -4930,7 +4934,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
DownloadStatus.finalizing,
progress: 0.99,
);
+123 -19
View File
@@ -20,6 +20,7 @@ final _prefs = SharedPreferences.getInstance();
class LocalLibraryState {
final List<LocalLibraryItem> items;
final bool isScanning;
final bool scanIsFinalizing;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
@@ -35,6 +36,7 @@ class LocalLibraryState {
LocalLibraryState({
this.items = const [],
this.isScanning = false,
this.scanIsFinalizing = false,
this.scanProgress = 0,
this.scanCurrentFile,
this.scanTotalFiles = 0,
@@ -85,6 +87,7 @@ class LocalLibraryState {
LocalLibraryState copyWith({
List<LocalLibraryItem>? items,
bool? isScanning,
bool? scanIsFinalizing,
double? scanProgress,
String? scanCurrentFile,
int? scanTotalFiles,
@@ -100,6 +103,7 @@ class LocalLibraryState {
return LocalLibraryState(
items: nextItems,
isScanning: isScanning ?? this.isScanning,
scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
scanProgress: scanProgress ?? this.scanProgress,
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
@@ -120,7 +124,8 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
final LibraryDatabase _db = LibraryDatabase.instance;
final HistoryDatabase _historyDb = HistoryDatabase.instance;
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? _progressStreamBootstrapTimer;
StreamSubscription<Map<String, dynamic>>? _progressStreamSub;
@@ -220,6 +225,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
);
state = state.copyWith(
isScanning: true,
scanIsFinalizing: false,
scanProgress: 0,
scanCurrentFile: null,
scanTotalFiles: 0,
@@ -297,11 +303,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
? await PlatformBridge.scanSafTree(effectiveFolderPath)
: await PlatformBridge.scanLibraryFolder(effectiveFolderPath);
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
await _showScanCancelledNotification();
return;
}
state = state.copyWith(
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
scanCurrentFile: null,
);
final items = <LocalLibraryItem>[];
int skippedDownloads = 0;
for (final json in results) {
@@ -334,11 +350,13 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(
items: persistedItems,
isScanning: false,
scanIsFinalizing: false,
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
excludedDownloadedCount: skippedDownloads,
);
await _pruneLibraryCoverCache(persistedItems);
_log.i(
'Full scan complete: ${persistedItems.length} tracks found, '
@@ -403,11 +421,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
if (_scanCancelRequested) {
state = state.copyWith(isScanning: false, scanWasCancelled: true);
state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
await _showScanCancelledNotification();
return;
}
state = state.copyWith(
scanIsFinalizing: true,
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
scanCurrentFile: null,
);
final scannedList =
(result['files'] as List<dynamic>?) ??
(result['scanned'] as List<dynamic>?) ??
@@ -497,6 +525,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
state = state.copyWith(
items: items,
isScanning: false,
scanIsFinalizing: false,
scanProgress: 100,
lastScannedAt: now,
scanWasCancelled: false,
@@ -516,7 +545,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
}
} catch (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());
} finally {
if (didStartSecurityAccess) {
@@ -573,16 +606,21 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
cancelOnError: false,
);
_progressStreamBootstrapTimer = Timer(const Duration(seconds: 3), () {
if (_hasReceivedProgressStreamEvent) {
return;
}
_log.w('Library scan progress stream timeout, fallback to polling');
_progressStreamSub?.cancel();
_progressStreamSub = null;
_usingProgressStream = false;
_startProgressPollingTimer();
});
Future<void>.microtask(_requestProgressSnapshot);
_progressStreamBootstrapTimer = Timer(
_progressStreamBootstrapTimeout,
() {
if (_hasReceivedProgressStreamEvent) {
return;
}
_log.w('Library scan progress stream timeout, fallback to polling');
_progressStreamSub?.cancel();
_progressStreamSub = null;
_usingProgressStream = false;
_startProgressPollingTimer();
},
);
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 {
final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0;
final normalizedProgress = ((nextProgress * 10).round() / 10).clamp(
0.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 totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0;
final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0;
final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0;
final isComplete = progress['is_complete'] == true;
final shouldUpdateState =
state.scanProgress != normalizedProgress ||
state.scanProgress != displayProgress ||
state.scanIsFinalizing != isComplete ||
state.scanCurrentFile != currentFile ||
state.scanTotalFiles != totalFiles ||
state.scannedFiles != scannedFiles ||
@@ -630,8 +689,9 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
if (shouldUpdateState) {
state = state.copyWith(
scanProgress: normalizedProgress,
scanCurrentFile: currentFile,
scanIsFinalizing: isComplete,
scanProgress: displayProgress,
scanCurrentFile: isComplete ? null : currentFile,
scanTotalFiles: totalFiles,
scannedFiles: scannedFiles,
scanErrorCount: errorCount,
@@ -704,7 +764,11 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_log.i('Cancelling library scan');
_scanCancelRequested = true;
await PlatformBridge.cancelLibraryScan();
state = state.copyWith(isScanning: false, scanWasCancelled: true);
state = state.copyWith(
isScanning: false,
scanIsFinalizing: false,
scanWasCancelled: true,
);
_stopProgressPolling();
await _showScanCancelledNotification();
}
@@ -815,6 +879,46 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
_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 {
await _db.delete(id);
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/constants/app_info.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/logger.dart';
@@ -260,6 +261,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setArtistTagMode(String mode) {
if (mode == artistTagModeJoined || mode == artistTagModeSplitVorbis) {
state = state.copyWith(artistTagMode: mode);
_saveSettings();
}
}
void setLyricsMode(String mode) {
if (mode == 'embed' || mode == 'external' || mode == 'both') {
state = state.copyWith(lyricsMode: mode);
+5 -84
View File
@@ -538,90 +538,11 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
url.startsWith('spotify:');
if (!isSpotifyUrl) {
state = TrackState(
isLoading: false,
error: 'url_not_recognized',
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,
);
}
state = TrackState(
isLoading: false,
error: 'url_not_recognized',
hasSearchText: state.hasSearchText,
);
} catch (e) {
if (!_isRequestValid(requestId)) return;
state = TrackState(
+86 -21
View File
@@ -174,42 +174,107 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
Map<String, dynamic> metadata;
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
metadata = await PlatformBridge.getDeezerMetadata(
final metadata = await PlatformBridge.getDeezerMetadata(
'album',
deezerAlbumId,
);
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('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
metadata = await PlatformBridge.getQobuzMetadata('album', qobuzAlbumId);
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:', '');
metadata = await PlatformBridge.getTidalMetadata('album', tidalAlbumId);
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}';
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
}
final result = await PlatformBridge.handleURLWithExtension(url);
if (result == null || result['tracks'] == null) {
throw StateError('Failed to load album metadata from extension');
}
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
final trackList = result['tracks'] 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();
final albumInfo = result['album'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
_AlbumCache.set(widget.albumId, tracks);
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_isLoading = false;
});
}
return;
}
} catch (e) {
if (mounted) {
+1 -16
View File
@@ -343,13 +343,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
headerImage = artistData['header_image'] as String?;
listeners = artistData['listeners'] as int?;
} else {
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(
url,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
throw StateError('Failed to load artist metadata from extension');
}
}
@@ -1105,15 +1099,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.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 [];
}
+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/lyrics_metadata_helper.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/settings_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
@@ -1164,19 +1165,23 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
final shouldEmbedLyrics =
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++) {
if (!mounted) break;
if (!mounted || cancelled) break;
final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final metadata = <String, String>{
@@ -1230,6 +1235,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf,
);
@@ -1335,6 +1341,9 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_exitSelectionMode();
if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
+11 -12
View File
@@ -2313,7 +2313,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
error.contains('429') ||
error.toLowerCase().contains('rate limit') ||
error.toLowerCase().contains('too many requests');
final isUrlNotRecognized = error == 'url_not_recognized';
if (isRateLimit) {
@@ -3087,7 +3086,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
final extState = ref.read(extensionProvider);
if (!extState.isInitialized) {
return 'Paste Spotify URL or search...';
return 'Paste supported URL or search...';
}
if (searchProvider != null && searchProvider.isNotEmpty) {
@@ -3108,7 +3107,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
return 'Search with ${ext.displayName}...';
}
}
return 'Paste Spotify URL or search...';
return 'Paste supported URL or search...';
}
Widget _buildSearchFilterBar(
@@ -3125,7 +3124,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: const Text('All'),
label: Text(context.l10n.historyFilterAll),
selected: selectedFilter == null,
onSelected: (_) {
ref.read(trackProvider.notifier).setSearchFilter(null);
@@ -4213,7 +4212,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
if (result == null) {
setState(() {
_error = 'Failed to load album';
_error = context.l10n.errorLoadAlbum;
_isLoading = false;
});
return;
@@ -4222,7 +4221,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
_error = 'No tracks found';
_error = context.l10n.errorNoTracksFound;
_isLoading = false;
});
return;
@@ -4244,7 +4243,7 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Error: $e';
_error = context.l10n.snackbarError(e.toString());
_isLoading = false;
});
}
@@ -4377,7 +4376,7 @@ class _ExtensionPlaylistScreenState
if (result == null) {
setState(() {
_error = 'Failed to load playlist';
_error = context.l10n.errorLoadPlaylist;
_isLoading = false;
});
return;
@@ -4386,7 +4385,7 @@ class _ExtensionPlaylistScreenState
final trackList = result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
_error = 'No tracks found';
_error = context.l10n.errorNoTracksFound;
_isLoading = false;
});
return;
@@ -4403,7 +4402,7 @@ class _ExtensionPlaylistScreenState
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Error: $e';
_error = context.l10n.snackbarError(e.toString());
_isLoading = false;
});
}
@@ -4529,7 +4528,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
if (result == null) {
setState(() {
_error = 'Failed to load artist';
_error = context.l10n.errorLoadArtist;
_isLoading = false;
});
return;
@@ -4563,7 +4562,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = 'Error: $e';
_error = context.l10n.snackbarError(e.toString());
_isLoading = false;
});
}
+19 -9
View File
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_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/services/library_database.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
@@ -1236,15 +1237,24 @@ class _CollectionTrackTile extends ConsumerWidget {
),
trailing: isSelectionMode
? null
: IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Icon(
Icons.more_vert,
color: colorScheme.onSurfaceVariant,
size: 20,
),
onPressed: () => _showTrackOptionsSheet(context, ref),
),
: historyItem != null || localItem != null
? IconButton(
tooltip: context.l10n.tooltipPlay,
onPressed: () {
ref
.read(playbackProvider.notifier)
.playTrackList([track]);
},
icon: Icon(
Icons.play_arrow,
color: colorScheme.primary,
),
style: IconButton.styleFrom(
backgroundColor: colorScheme.primaryContainer
.withValues(alpha: 0.3),
),
)
: null,
onTap: isSelectionMode
? 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/ffmpeg_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/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -816,6 +817,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
lowerPath.endsWith('.opus') ||
lowerPath.endsWith('.ogg');
final artistTagMode = ref.read(settingsProvider).artistTagMode;
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
@@ -834,6 +836,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
opusPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
artistTagMode: artistTagMode,
);
}
@@ -866,11 +869,13 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
Future<bool> _reEnrichLocalTrack(LocalLibraryItem item) async {
final durationMs = (item.duration ?? 0) * 1000;
final artistTagMode = ref.read(settingsProvider).artistTagMode;
final request = <String, dynamic>{
'file_path': item.filePath,
'cover_url': '',
'max_quality': true,
'embed_lyrics': true,
'artist_tag_mode': artistTagMode,
'spotify_id': '',
'track_name': item.trackName,
'artist_name': item.artistName,
@@ -957,16 +962,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
var skippedCount = 0;
final total = selected.length;
for (var i = 0; i < total; i++) {
if (!mounted) break;
var cancelled = false;
BatchProgressDialog.show(
context: context,
title: context.l10n.queueFlacAction,
total: total,
icon: Icons.queue_music,
onCancel: () {
cancelled = true;
BatchProgressDialog.dismiss(context);
},
);
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueFlacFindingProgress(i + 1, total)),
duration: const Duration(seconds: 30),
),
);
for (var i = 0; i < total; i++) {
if (!mounted || cancelled) break;
BatchProgressDialog.update(current: i + 1, detail: selected[i].trackName);
try {
final resolution = await LocalTrackRedownloadService.resolveBestMatch(
@@ -987,7 +998,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
ScaffoldMessenger.of(context).clearSnackBars();
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
if (matchedTracks.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -1063,18 +1076,25 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
var successCount = 0;
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++) {
if (!mounted) break;
if (!mounted || cancelled) break;
final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${context.l10n.trackReEnrichProgress} (${i + 1}/$total)',
),
duration: const Duration(seconds: 30),
),
BatchProgressDialog.update(
current: i + 1,
detail: '${item.trackName} - ${item.artistName}',
);
try {
@@ -1114,6 +1134,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
return;
}
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount;
final summary = failedCount <= 0
@@ -1422,19 +1445,23 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
final shouldEmbedLyrics =
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++) {
if (!mounted) break;
if (!mounted || cancelled) break;
final item = selected[i];
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.selectionBatchConvertProgress(i + 1, total),
),
duration: const Duration(seconds: 30),
),
);
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final metadata = <String, String>{
@@ -1487,6 +1514,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
artistTagMode: settings.artistTagMode,
deleteOriginal: !isSaf,
);
@@ -1621,6 +1649,9 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_exitSelectionMode();
if (mounted) {
if (!cancelled) {
BatchProgressDialog.dismiss(context);
}
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
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/track_provider.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/settings/settings_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_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/widgets/update_dialog.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
@@ -44,8 +45,14 @@ class _MainShellState extends ConsumerState<MainShell>
ShellNavigationService.homeTabNavigatorKey;
final GlobalKey<NavigatorState> _libraryTabNavigatorKey =
ShellNavigationService.libraryTabNavigatorKey;
final GlobalKey<NavigatorState> _storeTabNavigatorKey =
ShellNavigationService.storeTabNavigatorKey;
final GlobalKey<NavigatorState> _repoTabNavigatorKey =
ShellNavigationService.repoTabNavigatorKey;
@override
void didChangeDependencies() {
super.didChangeDependencies();
NotificationService().updateStrings(context.l10n);
}
@override
void initState() {
@@ -58,7 +65,7 @@ class _MainShellState extends ConsumerState<MainShell>
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: false,
showRepoTab: false,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
@@ -268,7 +275,7 @@ class _MainShellState extends ConsumerState<MainShell>
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
showRepoTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
// Jump directly when skipping intermediate tabs to avoid
@@ -295,17 +302,17 @@ class _MainShellState extends ConsumerState<MainShell>
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
showRepoTab: showStore,
);
FocusManager.instance.primaryFocus?.unfocus();
}
}
void _handleBackPress() {
Future<void> _handleBackPress() async {
final rootNavigator = Navigator.of(context, rootNavigator: true);
if (rootNavigator.canPop()) {
_log.i('Back: step 1 - root navigator pop');
rootNavigator.pop();
final handledByRootNavigator = await rootNavigator.maybePop();
if (handledByRootNavigator) {
_log.i('Back: step 1 - root navigator handled back');
_lastBackPress = null;
return;
}
@@ -314,9 +321,10 @@ class _MainShellState extends ConsumerState<MainShell>
settingsProvider.select((s) => s.showExtensionStore),
);
final currentNavigator = _navigatorForTab(_currentIndex, showStore);
if (currentNavigator != null && currentNavigator.canPop()) {
_log.i('Back: step 2 - tab navigator pop (tab=$_currentIndex)');
currentNavigator.pop();
final handledByCurrentNavigator =
await currentNavigator?.maybePop() ?? false;
if (handledByCurrentNavigator) {
_log.i('Back: step 2 - tab navigator handled back (tab=$_currentIndex)');
_lastBackPress = null;
return;
}
@@ -413,7 +421,7 @@ class _MainShellState extends ConsumerState<MainShell>
NavigatorState? _navigatorForTab(int index, bool showStore) {
if (index == 0) return _homeTabNavigatorKey.currentState;
if (index == 1) return _libraryTabNavigatorKey.currentState;
if (showStore && index == 2) return _storeTabNavigatorKey.currentState;
if (showStore && index == 2) return _repoTabNavigatorKey.currentState;
return null;
}
@@ -427,9 +435,9 @@ class _MainShellState extends ConsumerState<MainShell>
);
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showStoreTab: showStore,
showRepoTab: showStore,
);
final storeUpdatesCount = ref.watch(
final repoUpdatesCount = ref.watch(
storeProvider.select((s) => s.updatesAvailableCount),
);
@@ -446,9 +454,9 @@ class _MainShellState extends ConsumerState<MainShell>
),
if (showStore)
_TabNavigator(
key: const ValueKey('tab-store'),
navigatorKey: _storeTabNavigatorKey,
child: const StoreTab(),
key: const ValueKey('tab-repo'),
navigatorKey: _repoTabNavigatorKey,
child: const RepoTab(),
),
const SettingsTab(),
];
@@ -484,20 +492,20 @@ class _MainShellState extends ConsumerState<MainShell>
if (showStore)
NavigationDestination(
icon: AnimatedBadge(
count: storeUpdatesCount,
count: repoUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store_outlined),
isLabelVisible: repoUpdatesCount > 0,
label: Text('$repoUpdatesCount'),
child: const Icon(Icons.extension_outlined),
),
),
selectedIcon: SwingIcon(
selectedIcon: BouncingIcon(
child: AnimatedBadge(
count: storeUpdatesCount,
count: repoUpdatesCount,
child: Badge(
isLabelVisible: storeUpdatesCount > 0,
label: Text('$storeUpdatesCount'),
child: const Icon(Icons.store),
isLabelVisible: repoUpdatesCount > 0,
label: Text('$repoUpdatesCount'),
child: const Icon(Icons.extension),
),
),
),
@@ -522,7 +530,7 @@ class _MainShellState extends ConsumerState<MainShell>
return BackButtonListener(
onBackButtonPressed: () async {
_handleBackPress();
await _handleBackPress();
return true;
},
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/utils/app_bar_layout.dart';
class StoreTab extends ConsumerStatefulWidget {
const StoreTab({super.key});
class RepoTab extends ConsumerStatefulWidget {
const RepoTab({super.key});
@override
ConsumerState<StoreTab> createState() => _StoreTabState();
ConsumerState<RepoTab> createState() => _RepoTabState();
}
class _StoreTabState extends ConsumerState<StoreTab> {
class _RepoTabState extends ConsumerState<RepoTab> {
final _searchController = TextEditingController();
final _repoUrlController = TextEditingController();
bool _isInitialized = false;
@@ -323,7 +323,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.store_outlined,
Icons.extension_outlined,
size: 72,
color: colorScheme.onSurfaceVariant,
),
@@ -1359,7 +1359,9 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
if (ctx.mounted) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(
content: Text('Failed to open folder picker: $e'),
content: Text(
ctx.l10n.snackbarFolderPickerFailed(e.toString()),
),
backgroundColor: Theme.of(ctx).colorScheme.error,
duration: const Duration(seconds: 4),
),
+11 -9
View File
@@ -735,7 +735,7 @@ class _SearchProviderSelector extends ConsumerWidget {
(entry) => ListTile(
leading: Icon(Icons.search, color: colorScheme.tertiary),
title: Text(entry.value),
subtitle: Text('Search with ${entry.value}'),
subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)),
trailing: settings.searchProvider == entry.key
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -791,7 +791,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
final hasAnyProvider = homeFeedProviders.isNotEmpty;
String currentProviderName = 'Auto';
String currentProviderName = context.l10n.extensionsHomeFeedAuto;
if (settings.homeFeedProvider != null &&
settings.homeFeedProvider!.isNotEmpty) {
final ext = homeFeedProviders
@@ -828,7 +828,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Home Feed Provider',
context.l10n.extensionsHomeFeedProvider,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: !hasAnyProvider ? colorScheme.outline : null,
),
@@ -836,7 +836,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
const SizedBox(height: 2),
Text(
!hasAnyProvider
? 'No extensions with home feed'
? context.l10n.extensionsNoHomeFeedExtensions
: currentProviderName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
@@ -883,7 +883,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text(
'Home Feed Provider',
ctx.l10n.extensionsHomeFeedProvider,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
@@ -892,7 +892,7 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'Choose which extension provides the home feed on the main screen',
ctx.l10n.extensionsHomeFeedDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -900,8 +900,8 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
),
ListTile(
leading: Icon(Icons.auto_awesome, color: colorScheme.primary),
title: const Text('Auto'),
subtitle: const Text('Automatically select the best available'),
title: Text(ctx.l10n.extensionsHomeFeedAuto),
subtitle: Text(ctx.l10n.extensionsHomeFeedAutoSubtitle),
trailing:
(settings.homeFeedProvider == null ||
settings.homeFeedProvider!.isEmpty)
@@ -917,7 +917,9 @@ class _HomeFeedProviderSelector extends ConsumerWidget {
(ext) => ListTile(
leading: Icon(Icons.extension, color: colorScheme.secondary),
title: Text(ext.displayName),
subtitle: Text('Use ${ext.displayName} home feed'),
subtitle: Text(
ctx.l10n.extensionsHomeFeedUse(ext.displayName),
),
trailing: settings.homeFeedProvider == ext.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
@@ -392,6 +392,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
itemCount: libraryState.items.length,
excludedDownloadedCount: libraryState.excludedDownloadedCount,
isScanning: libraryState.isScanning,
scanIsFinalizing: libraryState.scanIsFinalizing,
scanProgress: libraryState.scanProgress,
scanCurrentFile: libraryState.scanCurrentFile,
scanTotalFiles: libraryState.scanTotalFiles,
@@ -528,8 +529,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
children: [
if (libraryState.isScanning)
_ScanProgressTile(
isFinalizing: libraryState.scanIsFinalizing,
progress: libraryState.scanProgress,
currentFile: libraryState.scanCurrentFile,
scannedFiles: libraryState.scannedFiles,
totalFiles: libraryState.scanTotalFiles,
onCancel: _cancelScan,
)
@@ -646,6 +649,7 @@ class _LibraryHeroCard extends StatelessWidget {
final int itemCount;
final int excludedDownloadedCount;
final bool isScanning;
final bool scanIsFinalizing;
final double scanProgress;
final String? scanCurrentFile;
final int scanTotalFiles;
@@ -656,6 +660,7 @@ class _LibraryHeroCard extends StatelessWidget {
required this.itemCount,
required this.excludedDownloadedCount,
required this.isScanning,
required this.scanIsFinalizing,
required this.scanProgress,
this.scanCurrentFile,
required this.scanTotalFiles,
@@ -680,6 +685,11 @@ class _LibraryHeroCard extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final showIndeterminateProgress =
isScanning &&
(scanIsFinalizing ||
scanTotalFiles <= 0 ||
(scannedFiles <= 0 && scanProgress <= 0));
final displayCount = isScanning
? scannedFiles
: itemCount + excludedDownloadedCount;
@@ -798,7 +808,7 @@ class _LibraryHeroCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
isScanning
? context.l10n.libraryTracksUnit(scannedFiles)
? context.l10n.libraryFilesUnit(scannedFiles)
: context.l10n.libraryTracksUnit(displayCount),
style: TextStyle(
fontSize: 16,
@@ -821,14 +831,49 @@ class _LibraryHeroCard extends StatelessWidget {
),
),
],
if (isScanning && scanCurrentFile != null) ...[
if (isScanning) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: scanProgress / 100,
value: showIndeterminateProgress
? null
: scanProgress / 100,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
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 ...[
const SizedBox(height: 8),
Row(
@@ -865,14 +910,18 @@ class _LibraryHeroCard extends StatelessWidget {
}
class _ScanProgressTile extends StatelessWidget {
final bool isFinalizing;
final double progress;
final String? currentFile;
final int scannedFiles;
final int totalFiles;
final VoidCallback onCancel;
const _ScanProgressTile({
required this.isFinalizing,
required this.progress,
this.currentFile,
required this.scannedFiles,
required this.totalFiles,
required this.onCancel,
});
@@ -880,6 +929,8 @@ class _ScanProgressTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final showIndeterminateProgress =
isFinalizing || totalFiles <= 0 || (scannedFiles <= 0 && progress <= 0);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -901,10 +952,14 @@ class _ScanProgressTile extends StatelessWidget {
),
),
Text(
context.l10n.libraryScanProgress(
progress.toStringAsFixed(0),
totalFiles,
),
isFinalizing
? context.l10n.libraryScanFinalizing
: totalFiles > 0
? context.l10n.libraryScanProgress(
progress.toStringAsFixed(0),
totalFiles,
)
: context.l10n.libraryScanning,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -920,12 +975,14 @@ class _ScanProgressTile extends StatelessWidget {
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: progress / 100,
value: showIndeterminateProgress ? null : progress / 100,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
if (currentFile != null) ...[
if (!isFinalizing &&
currentFile != null &&
currentFile!.trim().isNotEmpty) ...[
const SizedBox(height: 4),
Text(
currentFile!,
+3 -3
View File
@@ -519,7 +519,7 @@ class _LogEntryTile extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
entry.message,
entry.previewMessage,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
@@ -527,10 +527,10 @@ class _LogEntryTile extends StatelessWidget {
height: 1.4,
),
),
if (entry.error != null) ...[
if (entry.previewError != null) ...[
const SizedBox(height: 4),
Text(
entry.error!,
entry.previewError!,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
+103 -5
View File
@@ -2,9 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.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/settings_provider.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';
class OptionsSettingsPage extends ConsumerWidget {
@@ -115,7 +116,22 @@ class OptionsSettingsPage extends ConsumerWidget {
value: settings.embedMetadata,
onChanged: (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(
icon: Icons.image,
title: context.l10n.optionsMaxQualityCover,
@@ -158,7 +174,7 @@ class OptionsSettingsPage extends ConsumerWidget {
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.store,
icon: Icons.extension,
title: context.l10n.optionsExtensionStore,
subtitle: context.l10n.optionsExtensionStoreSubtitle,
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(
BuildContext context,
WidgetRef ref,
@@ -307,9 +405,9 @@ class OptionsSettingsPage extends ConsumerWidget {
} catch (e) {
if (context.mounted) {
Navigator.pop(context); // Close loading dialog
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
}
}
}
+6 -4
View File
@@ -339,7 +339,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to open folder picker: $e'),
content: Text(
context.l10n.snackbarFolderPickerFailed(e.toString()),
),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 4),
),
@@ -430,9 +432,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
if (mounted) context.go('/tutorial');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.snackbarError(e.toString()))),
);
}
} finally {
setState(() => _isLoading = false);
+18 -8
View File
@@ -1838,6 +1838,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
} catch (_) {}
final artistTagMode = ref.read(settingsProvider).artistTagMode;
String? ffmpegResult;
if (isMp3) {
ffmpegResult = await FFmpegService.embedMetadataToMp3(
@@ -1856,6 +1857,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
opusPath: workingPath,
coverPath: coverPath,
metadata: metadata,
artistTagMode: artistTagMode,
);
}
@@ -2228,6 +2230,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
if (!_fileExists) return;
try {
final artistTagMode = ref.read(settingsProvider).artistTagMode;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSearching)),
);
@@ -2238,6 +2241,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
'cover_url': _coverUrl ?? '',
'max_quality': true,
'embed_lyrics': true,
'artist_tag_mode': artistTagMode,
'spotify_id': _spotifyId ?? '',
'track_name': trackName,
'artist_name': artistName,
@@ -2340,6 +2344,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
opusPath: ffmpegTarget,
coverPath: effectiveCoverPath,
metadata: metadata,
artistTagMode: artistTagMode,
);
}
@@ -3554,6 +3559,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
bitrate: bitrate,
metadata: metadata,
coverPath: coverPath,
artistTagMode: ref.read(settingsProvider).artistTagMode,
deleteOriginal: !isSaf,
);
@@ -3768,6 +3774,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
initialValues: initialValues,
filePath: cleanFilePath,
sourceTrackId: _spotifyId,
artistTagMode: ref.read(settingsProvider).artistTagMode,
),
);
@@ -3989,12 +3996,14 @@ class _EditMetadataSheet extends StatefulWidget {
final Map<String, String> initialValues;
final String filePath;
final String? sourceTrackId;
final String artistTagMode;
const _EditMetadataSheet({
required this.colorScheme,
required this.initialValues,
required this.filePath,
this.sourceTrackId,
required this.artistTagMode,
});
@override
@@ -4875,6 +4884,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
'composer': _composerCtrl.text,
'comment': _commentCtrl.text,
'cover_path': _selectedCoverPath ?? '',
'artist_tag_mode': widget.artistTagMode,
};
try {
@@ -5005,6 +5015,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
opusPath: ffmpegTarget,
coverPath: existingCoverPath,
metadata: vorbisMap,
artistTagMode: widget.artistTagMode,
);
}
@@ -5025,9 +5036,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
if (ffmpegResult == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to save metadata via FFmpeg'),
),
SnackBar(content: Text(context.l10n.metadataSaveFailedFfmpeg)),
);
}
setState(() => _saving = false);
@@ -5038,9 +5047,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri);
if (!ok && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to write metadata back to storage'),
),
SnackBar(content: Text(context.l10n.metadataSaveFailedStorage)),
);
setState(() => _saving = false);
return;
@@ -5094,7 +5101,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
children: [
Expanded(
child: Text(
'Edit Metadata',
context.l10n.trackEditMetadata,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -5107,7 +5114,10 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
child: CircularProgressIndicator(strokeWidth: 2),
)
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,
description: l10n.tutorialExtensionsDesc,
content: _buildFeatureList(context, [
(Icons.storefront_rounded, l10n.tutorialExtensionsTip1),
(Icons.extension_rounded, l10n.tutorialExtensionsTip1),
(
Icons.add_circle_outline_rounded,
l10n.tutorialExtensionsTip2,
@@ -11,6 +11,7 @@ class DownloadRequestPayload {
final String filenameFormat;
final String quality;
final bool embedMetadata;
final String artistTagMode;
final bool embedLyrics;
final bool embedMaxQualityCover;
final int trackNumber;
@@ -49,6 +50,7 @@ class DownloadRequestPayload {
required this.filenameFormat,
this.quality = 'LOSSLESS',
this.embedMetadata = true,
this.artistTagMode = 'joined',
this.embedLyrics = true,
this.embedMaxQualityCover = true,
this.trackNumber = 1,
@@ -89,6 +91,7 @@ class DownloadRequestPayload {
'filename_format': filenameFormat,
'quality': quality,
'embed_metadata': embedMetadata,
'artist_tag_mode': artistTagMode,
'embed_lyrics': embedLyrics,
'embed_max_quality_cover': embedMaxQualityCover,
'track_number': trackNumber,
@@ -133,6 +136,7 @@ class DownloadRequestPayload {
filenameFormat: filenameFormat,
quality: quality,
embedMetadata: embedMetadata,
artistTagMode: artistTagMode,
embedLyrics: embedLyrics,
embedMaxQualityCover: embedMaxQualityCover,
trackNumber: trackNumber,
+119 -22
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/session_state.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('FFmpeg');
@@ -887,6 +888,7 @@ class FFmpegService {
required String flacPath,
String? coverPath,
Map<String, String>? metadata,
String artistTagMode = artistTagModeJoined,
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.flac');
@@ -911,10 +913,11 @@ class FFmpegService {
cmdBuffer.write('-c:a copy ');
if (metadata != null) {
metadata.forEach((key, value) {
final sanitizedValue = value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata $key="$sanitizedValue" ');
});
_appendVorbisMetadataToCommandBuffer(
cmdBuffer,
metadata,
artistTagMode: artistTagMode,
);
}
cmdBuffer.write('"$tempOutput" -y');
@@ -1046,6 +1049,7 @@ class FFmpegService {
required String opusPath,
String? coverPath,
Map<String, String>? metadata,
String artistTagMode = artistTagModeJoined,
}) async {
final tempDir = await getTemporaryDirectory();
final tempOutput = _nextTempEmbedPath(tempDir.path, '.opus');
@@ -1063,11 +1067,11 @@ class FFmpegService {
];
if (metadata != null) {
metadata.forEach((key, value) {
arguments
..add('-metadata')
..add('$key=$value');
});
_appendVorbisMetadataToArguments(
arguments,
metadata,
artistTagMode: artistTagMode,
);
}
if (coverPath != null) {
@@ -1154,8 +1158,11 @@ class FFmpegService {
// For M4A/MP4, cover art is mapped as a video stream and stored in the
// 'covr' atom automatically by FFmpeg. The '-disposition attached_pic'
// 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) {
cmdBuffer.write('-map 1:v -c:v copy ');
cmdBuffer.write('-map 1:v -c:v copy -f mp4 ');
}
cmdBuffer.write('-c:a copy ');
@@ -1326,6 +1333,7 @@ class FFmpegService {
required String bitrate,
required Map<String, String> metadata,
String? coverPath,
String artistTagMode = artistTagModeJoined,
bool deleteOriginal = true,
}) async {
final format = targetFormat.toLowerCase();
@@ -1348,6 +1356,7 @@ class FFmpegService {
inputPath: inputPath,
metadata: metadata,
coverPath: coverPath,
artistTagMode: artistTagMode,
deleteOriginal: deleteOriginal,
);
}
@@ -1391,6 +1400,7 @@ class FFmpegService {
opusPath: outputPath,
coverPath: coverPath,
metadata: metadata,
artistTagMode: artistTagMode,
);
}
@@ -1446,10 +1456,10 @@ class FFmpegService {
}
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) {
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('-map_metadata -1 ');
@@ -1491,6 +1501,7 @@ class FFmpegService {
required String inputPath,
required Map<String, String> metadata,
String? coverPath,
String artistTagMode = artistTagModeJoined,
bool deleteOriginal = true,
}) async {
final outputPath = _buildOutputPath(inputPath, '.flac');
@@ -1515,11 +1526,11 @@ class FFmpegService {
cmdBuffer.write('-c:a flac -compression_level 8 ');
cmdBuffer.write('-map_metadata 0 ');
final vorbisComments = _normalizeToVorbisComments(metadata);
for (final entry in vorbisComments.entries) {
final sanitized = entry.value.replaceAll('"', '\\"');
cmdBuffer.write('-metadata ${entry.key}="$sanitized" ');
}
_appendVorbisMetadataToCommandBuffer(
cmdBuffer,
metadata,
artistTagMode: artistTagMode,
);
cmdBuffer.write('"$outputPath" -y');
@@ -1617,6 +1628,86 @@ class FFmpegService {
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.
static Map<String, String> _convertToM4aTags(Map<String, String> metadata) {
final m4aMap = <String, String>{};
@@ -1691,6 +1782,9 @@ class FFmpegService {
final key = entry.key.toUpperCase();
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
final value = entry.value;
if (value.trim().isEmpty) {
continue;
}
switch (normalizedKey) {
case 'TITLE':
@@ -1708,12 +1802,16 @@ class FFmpegService {
case 'TRACKNUMBER':
case 'TRACK':
case 'TRCK':
id3Map['track'] = value;
if (value != '0') {
id3Map['track'] = value;
}
break;
case 'DISCNUMBER':
case 'DISC':
case 'TPOS':
id3Map['disc'] = value;
if (value != '0') {
id3Map['disc'] = value;
}
break;
case 'DATE':
case 'YEAR':
@@ -1836,8 +1934,7 @@ class FFmpegService {
continue;
}
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {
}
if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') {}
outputPaths.add(outputPath);
_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_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:spotiflac_android/l10n/app_localizations.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
@@ -13,6 +14,13 @@ class NotificationService {
FlutterLocalNotificationsPlugin();
bool _isInitialized = 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 updateDownloadId = 2;
@@ -165,7 +173,8 @@ class NotificationService {
await _showSafely(
id: downloadProgressId,
title: 'Downloading $trackName',
title:
_l10n?.notifDownloadingTrack(trackName) ?? 'Downloading $trackName',
body: '$artistName$percentage%',
details: details,
);
@@ -208,8 +217,9 @@ class NotificationService {
await _showSafely(
id: downloadProgressId,
title: 'Finalizing $trackName',
body: '$artistName • Embedding metadata...',
title: _l10n?.notifFinalizingTrack(trackName) ?? 'Finalizing $trackName',
body:
'$artistName${_l10n?.notifEmbeddingMetadata ?? 'Embedding metadata...'}',
details: details,
);
}
@@ -226,12 +236,14 @@ class NotificationService {
String title;
if (alreadyInLibrary) {
title = completedCount != null && totalCount != null
? 'Already in Library ($completedCount/$totalCount)'
: 'Already in Library';
? (_l10n?.notifAlreadyInLibraryCount(completedCount, totalCount) ??
'Already in Library ($completedCount/$totalCount)')
: (_l10n?.notifAlreadyInLibrary ?? 'Already in Library');
} else {
title = completedCount != null && totalCount != null
? 'Download Complete ($completedCount/$totalCount)'
: 'Download Complete';
? (_l10n?.notifDownloadCompleteCount(completedCount, totalCount) ??
'Download Complete ($completedCount/$totalCount)')
: (_l10n?.notifDownloadComplete ?? 'Download Complete');
}
const androidDetails = AndroidNotificationDetails(
@@ -271,8 +283,9 @@ class NotificationService {
if (!_isInitialized) await initialize();
final title = failedCount > 0
? 'Downloads Finished ($completedCount done, $failedCount failed)'
: 'All Downloads Complete';
? (_l10n?.notifDownloadsFinished(completedCount, failedCount) ??
'Downloads Finished ($completedCount done, $failedCount failed)')
: (_l10n?.notifAllDownloadsComplete ?? 'All Downloads Complete');
const androidDetails = AndroidNotificationDetails(
channelId,
@@ -299,7 +312,9 @@ class NotificationService {
await _showSafely(
id: downloadProgressId,
title: title,
body: '$completedCount tracks downloaded successfully',
body:
_l10n?.notifTracksDownloadedSuccess(completedCount) ??
'$completedCount tracks downloaded successfully',
details: details,
);
}
@@ -319,8 +334,14 @@ class NotificationService {
final clampedProgress = progress.clamp(0.0, 100.0);
final percentage = clampedProgress.round();
final progressBody = totalFiles > 0
? '$scannedFiles/$totalFiles files • $percentage%'
: '$scannedFiles files scanned • $percentage%';
? (_l10n?.notifLibraryScanProgressWithTotal(
scannedFiles,
totalFiles,
percentage,
) ??
'$scannedFiles/$totalFiles files • $percentage%')
: (_l10n?.notifLibraryScanProgressNoTotal(scannedFiles, percentage) ??
'$scannedFiles files scanned • $percentage%');
final body = (currentFile != null && currentFile.isNotEmpty)
? '$progressBody\n$currentFile'
: progressBody;
@@ -355,7 +376,7 @@ class NotificationService {
await _showSafely(
id: libraryScanId,
title: 'Scanning local library',
title: _l10n?.notifScanningLibrary ?? 'Scanning local library',
body: body,
details: details,
);
@@ -370,10 +391,15 @@ class NotificationService {
final extras = <String>[];
if (excludedDownloadedCount > 0) {
extras.add('$excludedDownloadedCount excluded');
extras.add(
_l10n?.notifLibraryScanExcluded(excludedDownloadedCount) ??
'$excludedDownloadedCount excluded',
);
}
if (errorCount > 0) {
extras.add('$errorCount errors');
extras.add(
_l10n?.notifLibraryScanErrors(errorCount) ?? '$errorCount errors',
);
}
final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})';
@@ -401,8 +427,9 @@ class NotificationService {
await _showSafely(
id: libraryScanId,
title: 'Library scan complete',
body: '$totalTracks tracks indexed$suffix',
title: _l10n?.notifLibraryScanComplete ?? 'Library scan complete',
body:
'${_l10n?.notifLibraryScanCompleteBody(totalTracks) ?? '$totalTracks tracks indexed'}$suffix',
details: details,
);
}
@@ -434,7 +461,7 @@ class NotificationService {
await _showSafely(
id: libraryScanId,
title: 'Library scan failed',
title: _l10n?.notifLibraryScanFailed ?? 'Library scan failed',
body: message,
details: details,
);
@@ -467,8 +494,8 @@ class NotificationService {
await _showSafely(
id: libraryScanId,
title: 'Library scan cancelled',
body: 'Scan stopped before completion.',
title: _l10n?.notifLibraryScanCancelled ?? 'Library scan cancelled',
body: _l10n?.notifLibraryScanStopped ?? 'Scan stopped before completion.',
details: details,
);
}
@@ -518,8 +545,12 @@ class NotificationService {
await _showSafely(
id: updateDownloadId,
title: 'Downloading SpotiFLAC v$version',
body: '$receivedMB / $totalMB MB • $percentage%',
title:
_l10n?.notifDownloadingUpdate(version) ??
'Downloading SpotiFLAC v$version',
body:
_l10n?.notifUpdateProgress(receivedMB, totalMB, percentage) ??
'$receivedMB / $totalMB MB • $percentage%',
details: details,
);
}
@@ -551,8 +582,10 @@ class NotificationService {
await _showSafely(
id: updateDownloadId,
title: 'Update Ready',
body: 'SpotiFLAC v$version downloaded. Tap to install.',
title: _l10n?.notifUpdateReady ?? 'Update Ready',
body:
_l10n?.notifUpdateReadyBody(version) ??
'SpotiFLAC v$version downloaded. Tap to install.',
details: details,
);
}
@@ -583,8 +616,10 @@ class NotificationService {
await _showSafely(
id: updateDownloadId,
title: 'Update Failed',
body: 'Could not download update. Try again later.',
title: _l10n?.notifUpdateFailed ?? 'Update Failed',
body:
_l10n?.notifUpdateFailedBody ??
'Could not download update. Try again later.',
details: details,
);
}
-16
View File
@@ -20,12 +20,6 @@ class PlatformBridge {
static bool get supportsExtensionSystem =>
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(
String spotifyId,
String isrc,
@@ -654,16 +648,6 @@ class PlatformBridge {
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 {
final result = await _channel.invokeMethod('getLogs');
final logs = jsonDecode(result as String) as List<dynamic>;
+6 -6
View File
@@ -5,25 +5,25 @@ class ShellNavigationService {
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> libraryTabNavigatorKey =
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> storeTabNavigatorKey =
static final GlobalKey<NavigatorState> repoTabNavigatorKey =
GlobalKey<NavigatorState>();
static int _currentTabIndex = 0;
static bool _showStoreTab = false;
static bool _showRepoTab = false;
static void syncState({
required int currentTabIndex,
required bool showStoreTab,
required bool showRepoTab,
}) {
_currentTabIndex = currentTabIndex;
_showStoreTab = showStoreTab;
_showRepoTab = showRepoTab;
}
static NavigatorState? activeTabNavigator() {
if (_currentTabIndex == 0) return homeTabNavigatorKey.currentState;
if (_currentTabIndex == 1) return libraryTabNavigatorKey.currentState;
if (_showStoreTab && _currentTabIndex == 2) {
return storeTabNavigatorKey.currentState;
if (_showRepoTab && _currentTabIndex == 2) {
return repoTabNavigatorKey.currentState;
}
return null;
}
+25
View File
@@ -3,6 +3,9 @@ final RegExp _artistNameSplitPattern = RegExp(
caseSensitive: false,
);
const artistTagModeJoined = 'joined';
const artistTagModeSplitVorbis = 'split_vorbis';
List<String> splitArtistNames(String rawArtists) {
final raw = rawArtists.trim();
if (raw.isEmpty) return const [];
@@ -13,3 +16,25 @@ List<String> splitArtistNames(String rawArtists) {
.where((part) => part.isNotEmpty)
.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';
}
String get previewMessage => _truncateLogText(message);
String? get previewError => error == null ? null : _truncateLogText(error!);
@override
String toString() {
final errorPart = error != null ? ' | $error' : '';
@@ -128,11 +132,9 @@ class LogBuffer extends ChangeNotifier {
return;
}
final sanitizedMessage = _truncateLogText(
_redactSensitiveText(entry.message),
);
final sanitizedMessage = _redactSensitiveText(entry.message);
final sanitizedError = entry.error != null
? _truncateLogText(_redactSensitiveText(entry.error!))
? _redactSensitiveText(entry.error!)
: null;
final sanitizedEntry =
(sanitizedMessage == entry.message && sanitizedError == entry.error)
@@ -381,9 +383,7 @@ class BufferedOutput extends LogOutput {
}
final level = _levelToString(event.level);
final message = _truncateLogText(
_redactSensitiveText(event.lines.join('\n')),
);
final message = _redactSensitiveText(event.lines.join('\n'));
LogBuffer().add(
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 [
const QualityOption(
id: 'DEFAULT',
label: 'Default Quality',
description: 'Best available',
),
];
// Extensions without quality options use Tidal's options as default
// since the download will fall back to built-in providers anyway.
return _builtInServices.firstWhere((s) => s.id == 'tidal').qualityOptions;
}
@override
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer
publish_to: "none"
version: 4.1.1+118
version: 4.1.3+120
environment:
sdk: ^3.10.0